Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 177 additions & 20 deletions src/agnostic_guard_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
//! Walks every source file under `src/` at test time (`.zig`, plus the
//! compiled `.c`/`.h` vendored there), splits each into `[A-Za-z0-9]+` runs
//! and compares each run case-insensitively against `forbidden`, first as a
//! whole and then piece by piece at CamelCase boundaries. A token that ends
//! whole and then at CamelCase boundaries (including listed compounds that
//! span pieces, such as `UIKit` in `UIKitGlue`). A token that ends
//! in digits is also compared by its letter root, so a version or bit-width
//! suffix does not hide a name. So `ios_cmd`, `cli/android/`, `IosConfig`,
//! `iOS`, `iOSConfig`, `getSDLPath`, `wasm32`, `android14`, `sdl3` and
Expand All @@ -19,7 +20,9 @@
//! naming a platform is a finding too. The JSON wire fixtures under
//! `cli/provider_contract/` are data, not source, and stay out of scope:
//! they describe what a provider may send, so a provider name there is not
//! core code knowing a platform.
//! core code knowing a platform. Source-shaped symlinks are rejected, even
//! when allowlisted: neither the link name nor unread target contents may
//! bypass the guard (and dangling, external or cyclic targets are not read).
//!
//! Files that still carry legacy platform code sit on `allowed_files`, a
//! migration allowlist that can only SHRINK: the test also fails when an
Expand All @@ -36,10 +39,45 @@ const std = @import("std");
/// `ios`: `macos` is a host the core runs on, `tvos`, `watchos`, `visionos`
/// and `maccatalyst` are only ever targets.
const forbidden = [_][]const u8{
"android", "ios", "web", "wasm", "emsdk", "emscripten", "steam",
"itch", "xcode", "gradle", "apk", "aab", "ndk", "raylib",
"sokol", "sdl", "sdl2", "bgfx", "wgpu", "tvos", "watchos",
"visionos", "maccatalyst",
"android",
"ios",
"web",
"wasm",
"emsdk",
"emscripten",
"steam",
"itch",
"xcode",
"gradle",
"apk",
"aab",
"ndk",
"raylib",
"sokol",
"sdl",
"sdl2",
"bgfx",
"wgpu",
"tvos",
"watchos",
"visionos",
"maccatalyst",

// RFC #406 / #419 provider identities, not general affix stripping:
// Steam's SDK/CLI, itch's publisher, iOS's UI SDK/build tool, Android's
// device bridge and the web compiler all belong to their providers.
"steamworks",
"steamcmd",
"butler",
"uikit",
"xcodebuild",
"adb",
"emcc",

// SDL's conventional library/package prefix (libsdl2-dev, libSDL3.so).
// The numeric-root rule covers later versions without banning all libs.
"libsdl",
"libsdl2",
};

/// Host OS names: the core legitimately branches on the OS it runs on,
Expand All @@ -56,8 +94,12 @@ const allowed_words = [_][]const u8{ "macos", "windows", "linux", "darwin", "win
/// `web` joined, and once more after the numeric-suffix rule (`wasm32`,
/// `sdl3`) and the Apple targets (`tvos`, `watchos`, `visionos`,
/// `maccatalyst`, spelled in `astc/cmd.zig`) joined (still 53 entries: every
/// file those reach was already listed). Shrink only: an entry whose file is
/// clean fails the test until it is removed. Note the path scan: an entry
/// file those reach was already listed). Rechecked for #419's compound,
/// package and tool/SDK identities: still 53 entries, no new exemptions.
/// Existing SDL provisioning/doctor, mobile SDK and emsdk migration files
/// already cover the newly detected uses; provider-neutral files stay clean.
/// Shrink only: an entry whose file is clean fails until it is removed.
/// Note the path scan: an entry
/// under `cli/android/` or named `cli/ios.zig` stays dirty until the file is
/// moved or renamed, not merely emptied of platform words.
const allowed_files = [_][]const u8{
Expand Down Expand Up @@ -203,7 +245,8 @@ fn splitsBefore(text: []const u8, start: usize, i: usize) bool {

/// Yields the forbidden words of `text` in order of occurrence. Each
/// `[A-Za-z0-9]+` run is classified whole first (`iOS`, `ANDROID`), then,
/// when the whole run is not a forbidden word, once per CamelCase piece.
/// when the whole run is not forbidden, at CamelCase boundaries. Listed
/// compounds can span pieces (UI|Kit); unrelated pieces remain separate.
const Tokenizer = struct {
text: []const u8,
pos: usize = 0,
Expand All @@ -226,6 +269,20 @@ const Tokenizer = struct {
}
}
const start = self.pos;
// A listed name can itself span acronym boundaries (UI|Kit).
// Match only complete prefixes ending at a CamelCase boundary,
// never a substring such as UIKit in UIKitten or UIKithelper.
var end = start + 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect UIKit after one-letter lowercase prefixes

When the newly forbidden compound follows a one-letter lowercase prefix, such as aUIKitGlue or kUIKit2Version, splitsBefore suppresses the initial lower-to-upper boundary under its lowercase-leading-acronym rule. The fallback consequently consumes aUI/kUI as the first piece, so this prefix search never begins at U and the tokenizer emits no finding, allowing UIKit-specific identifiers to bypass the guard.

Useful? React with 👍 / 👎.

while (end <= self.run_end and end - start <= max_word_len) : (end += 1) {
if (classifyExact(self.text[start..end]) == null) continue;
// Versions may follow the compound before the boundary.
var boundary = end;
while (boundary < self.run_end and std.ascii.isDigit(self.text[boundary])) boundary += 1;
if (boundary != self.run_end and !splitsBefore(self.text, start, boundary)) continue;
self.pos = boundary;
// Preserve exact entries such as sdl2 over the sdl root.
return classify(self.text[start..boundary]);
}
self.pos += 1;
while (self.pos < self.run_end and !splitsBefore(self.text, start, self.pos)) self.pos += 1;
if (classify(self.text[start..self.pos])) |word| return word;
Expand Down Expand Up @@ -260,6 +317,30 @@ const Scan = struct {
self.offenders.deinit(self.gpa);
}

/// Return whether to read an entry. Never follow source-shaped links:
/// rejection applies before the allowlist and does not trust the target.
fn sourceEntry(self: *Scan, path: []const u8, kind: std.Io.File.Kind) !bool {
if (!isSource(path)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject symlinked source directories before filtering extensions

When a tracked path such as src/cli/platform is a symlink to a directory containing imported .zig files, the walker reports the alias itself as .sym_link and does not descend into it. Because this extension check returns before examining the entry kind, the directory link is neither rejected nor scanned, so sources compiled through @import("platform/file.zig") can still contain forbidden provider identities without failing the guard.

Useful? React with 👍 / 👎.

if (kind == .sym_link) {
const msg = try std.fmt.allocPrint(self.gpa, "src/{s}: source symlink is not allowed; use a regular source file", .{path});
errdefer self.gpa.free(msg);
try self.offenders.append(self.gpa, msg);
return false;
}
return kind == .file;
}

fn tree(self: *Scan, io: std.Io, src: std.Io.Dir) !void {
var walker = try src.walk(self.gpa);
defer walker.deinit();
while (try walker.next(io)) |entry| {
if (!try self.sourceEntry(entry.path, entry.kind)) continue;
const bytes = try entry.dir.readFileAlloc(io, entry.basename, self.gpa, .limited(4 << 20));
defer self.gpa.free(bytes);
try self.file(entry.path, bytes);
}
}

/// Note one file: its path (relative to `src/`, scanned first, every
/// segment) and then its contents, line by line.
fn file(self: *Scan, path: []const u8, bytes: []const u8) !void {
Expand Down Expand Up @@ -305,7 +386,35 @@ fn expectWords(text: []const u8, expected: []const []const u8) !void {
test "the tokenizer flags whole alphanumeric runs, case-insensitively" {
try expectWords("std.Io ios_cmd biosphere Android cli/android/run.zig SDL2_image", &.{ "ios", "android", "android", "sdl2" });
// Substrings inside a longer lowercase run never match: `wasm` is not a word here.
try expectWords("wasmtime bgfxdebug libsdl", &.{});
try expectWords("wasmtime bgfxdebug libpng", &.{});
}

test "known compound and package forms flag without matching benign substrings" {
try expectWords("Steamworks STEAMWORKS SteamworksSdk steamworks_sdk", &.{ "steamworks", "steamworks", "steamworks", "steamworks" });
try expectWords("libsdl libsdl2-dev libsdl2-mixer-dev libSDL2.so LIBSDL3 Libsdl3Package", &.{ "libsdl", "libsdl2", "libsdl2", "libsdl2", "libsdl", "libsdl" });
// ASCII controls: known identities, not arbitrary lib/works/sdk suffixes.
try expectWords("frameworks clockworks libpng libz libcurl2-dev sdk dev steamworking libsdlhelper", &.{});
var scan: Scan = .{ .gpa = std.testing.allocator };
defer scan.deinit();
try scan.file("cli/libsdl2-dev.zig", "// Steamworks\n");
try std.testing.expectEqual(@as(usize, 2), scan.offenders.items.len);
try std.testing.expectEqualStrings("src/cli/libsdl2-dev.zig: 'libsdl2' in path " ++ finding_note, scan.offenders.items[0]);
try std.testing.expectEqualStrings("src/cli/libsdl2-dev.zig:1: 'steamworks' " ++ finding_note, scan.offenders.items[1]);
try scan.file("cli/libpng-dev.zig", "// clockworks\n");
try std.testing.expectEqual(@as(usize, 2), scan.offenders.items.len);
}

test "RFC provider tools and SDK identities flag in commands paths and identifiers" {
try expectWords("butler UIKit steamcmd xcodebuild adb emcc", &.{ "butler", "uikit", "steamcmd", "xcodebuild", "adb", "emcc" });
try expectWords("ButlerPublisher UIKitGlue SteamcmdTool XcodebuildRunner AdbDevice EmccCompiler", &.{ "butler", "uikit", "steamcmd", "xcodebuild", "adb", "emcc" });
try expectWords("BUTLER.EXE UIKit/UIKit.h bin/steamcmd --xcodebuild adb.exe emcc.py", &.{ "butler", "uikit", "uikit", "steamcmd", "xcodebuild", "adb", "emcc" });
// Host tools and generic contract vocabulary remain available to core.
try expectWords("zig cc clang git SDK tool compiler publisher device build macos windows linux", &.{});
try expectWords("butlers uikits steamcmdline xcodebuilder adblock emcclib", &.{});
try expectWords("getUIKitPath UIKitGlue UIKitten UIKithelper UIKeyboard Toolkit", &.{ "uikit", "uikit" });
// Compound SDK names retain the same numeric-suffix rule as one piece.
try expectWords("UIKit2Glue getUIKit123456789012345Path LibSDL2Package", &.{ "uikit", "uikit", "libsdl2" });
try expectWords("UIKit2helper UIKitten2Glue LibPNG2Package", &.{});
}

test "the tokenizer splits CamelCase at case transitions and acronym boundaries" {
Expand Down Expand Up @@ -351,7 +460,7 @@ test "a numeric suffix does not hide a forbidden root" {
try expectWords("android14", &.{"android"});
try expectWords("sdl3", &.{"sdl"});
try expectWords("bgfx2 SOKOL3 Emsdk4", &.{ "bgfx", "sokol", "emsdk" });
try expectWords("target = .wasm32; api >= android14; libSDL3", &.{ "wasm", "android", "sdl" });
try expectWords("target = .wasm32; api >= android14; libSDL3", &.{ "wasm", "android", "libsdl" });
// The exact table entry wins over the root: `sdl2` is listed as such.
try expectWords("sdl2 SDL2_image", &.{ "sdl2", "sdl2" });
// CamelCase pieces get the same treatment.
Expand Down Expand Up @@ -512,22 +621,70 @@ fn containsString(haystack: []const []const u8, needle: []const u8) bool {
return false;
}

test "source symlink rejection precedes the allowlist and root sentinel" {
var scan: Scan = .{ .gpa = std.testing.allocator };
defer scan.deinit();
for ([_][]const u8{ "cli/steam.zig", "cli/provider.zig", "cli/bridge.c", "cli/bridge.h", "cli/ios.zig", cli_root }) |path| {
try std.testing.expect(!try scan.sourceEntry(path, .sym_link));
}
try std.testing.expectEqual(@as(usize, 6), scan.offenders.items.len);
try std.testing.expectEqualStrings("src/cli/steam.zig: source symlink is not allowed; use a regular source file", scan.offenders.items[0]);
try std.testing.expect(!scan.saw_cli_root);
try std.testing.expect(!scan.dirty[allowedIndex("cli/ios.zig").?]);
// Identical ASCII names as regular files are read; data links stay out
// of scope, as do directories (the walker visits their children).
try std.testing.expect(try scan.sourceEntry("cli/provider.zig", .file));
try std.testing.expect(try scan.sourceEntry("cli/bridge.c", .file));
try std.testing.expect(try scan.sourceEntry("cli/bridge.h", .file));
try std.testing.expect(!try scan.sourceEntry("cli/fixture.json", .sym_link));
try std.testing.expect(!try scan.sourceEntry("cli/folder.zig", .directory));
try std.testing.expectEqual(@as(usize, 6), scan.offenders.items.len);
}

test "the real walker rejects source links with dirty names or hidden contents" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = cli_root, .data = "const value = 1;\n" });
try tmp.dir.writeFile(io, .{ .sub_path = "clean.txt", .data = "// benign ASCII\n" });
try tmp.dir.writeFile(io, .{ .sub_path = "hidden.txt", .data = "// Steamworks UIKit\n" });
var clean: Scan = .{ .gpa = std.testing.allocator };
defer clean.deinit();
try clean.tree(io, tmp.dir);
try std.testing.expect(clean.saw_cli_root);
try std.testing.expectEqual(@as(usize, 0), clean.offenders.items.len);

tmp.dir.symLink(io, "clean.txt", "steam.zig", .{}) catch |err| switch (err) {
// Windows may lack symlink privileges; the entry-policy test above
// still exercises rejection there. Other failures are real failures.
error.AccessDenied, error.PermissionDenied => if (@import("builtin").os.tag == .windows) return error.SkipZigTest else return err,
else => return err,
};
try tmp.dir.symLink(io, "hidden.txt", "provider.zig", .{});
try tmp.dir.symLink(io, "hidden.txt", "bridge.c", .{});
try tmp.dir.symLink(io, "missing.txt", "bridge.h", .{});
try tmp.dir.symLink(io, "loop.zig", "loop.zig", .{});
try tmp.dir.symLink(io, "hidden.txt", "fixture.json", .{});
var linked: Scan = .{ .gpa = std.testing.allocator };
defer linked.deinit();
try linked.tree(io, tmp.dir);
try std.testing.expect(linked.saw_cli_root);
try std.testing.expectEqual(@as(usize, 5), linked.offenders.items.len);
for ([_][]const u8{ "steam.zig", "provider.zig", "bridge.c", "bridge.h", "loop.zig" }) |path| {
const expected = try std.fmt.allocPrint(std.testing.allocator, "src/{s}: source symlink is not allowed; use a regular source file", .{path});
defer std.testing.allocator.free(expected);
try std.testing.expect(containsString(linked.offenders.items, expected));
}
}

test "no core file names a platform, store, package or backend" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var src = try std.Io.Dir.cwd().openDir(io, "src", .{ .iterate = true });
defer src.close(io);
var walker = try src.walk(gpa);
defer walker.deinit();

var scan: Scan = .{ .gpa = gpa };
defer scan.deinit();
while (try walker.next(io)) |entry| {
if (entry.kind != .file or !isSource(entry.path)) continue;
const bytes = try entry.dir.readFileAlloc(io, entry.basename, gpa, .limited(4 << 20));
defer gpa.free(bytes);
try scan.file(entry.path, bytes);
}
try scan.tree(io, src);
var stale: std.ArrayList([]const u8) = .empty;
defer stale.deinit(gpa);
try scan.stale(&stale);
Expand Down
Loading