const std = @import("std"); const spec_data = @import("spec_data"); const memory_size = 16 * 1024 * 1024; const path_bytes_max: usize = @intCast(spec_data.limit_value.raw_path_bytes); const tracked_paths_max: usize = @intCast(spec_data.limit_value.retained_findings); const child_stdout_max: usize = @intCast(spec_data.limit_value.captured_stdout_bytes); const child_stderr_max: usize = @intCast(spec_data.limit_value.normalized_diagnostics_bytes); const io_buffer_bytes = 4096; var memory: [memory_size]u8 = undefined; comptime { std.debug.assert(path_bytes_max > 0); std.debug.assert(tracked_paths_max > 0); std.debug.assert(child_stdout_max > 0); std.debug.assert(child_stderr_max > 0); } const Kind = enum { missing, file, sym_link, other, }; const Digest = [std.crypto.hash.sha2.Sha256.digest_length]u8; pub const Entry = struct { path: []const u8, kind: Kind, size: u64, digest: Digest, }; const Snapshot = struct { entries: []Entry, git_stdout: []u8, git_stderr: []u8, fn deinit(self: *Snapshot, allocator: std.mem.Allocator) void { allocator.free(self.entries); allocator.free(self.git_stdout); allocator.free(self.git_stderr); self.* = undefined; } }; pub fn main(init: std.process.Init.Minimal) !void { var fixed = std.heap.FixedBufferAllocator.init(&memory); const allocator = fixed.allocator(); var threaded: std.Io.Threaded = .init(allocator, .{ .environ = init.environ, .argv0 = .init(init.args), }); defer threaded.deinit(); const io = threaded.io(); const args = try init.args.toSlice(allocator); if (args.len < 4) return error.InvalidGateArguments; if (!std.mem.eql(u8, args[2], "build")) return error.InvalidGateCommand; const step = args[3]; if (!std.mem.eql(u8, step, "check-inner") and !std.mem.eql(u8, step, "ci-inner")) { return error.InvalidGateStep; } var before = try snapshot(allocator, io); defer before.deinit(allocator); const build_result = try run_inner_build(allocator, io, args[1..]); defer allocator.free(build_result.stdout); defer allocator.free(build_result.stderr); if (!successful(build_result.term)) { try report_inner_failure(io, build_result); return error.InnerGateFailed; } var after = try snapshot(allocator, io); defer after.deinit(allocator); var stderr_buffer: [io_buffer_bytes]u8 = undefined; var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer); const comparison = compare(before.entries, after.entries, &stderr_writer.interface); try stderr_writer.interface.flush(); try comparison; } fn snapshot(allocator: std.mem.Allocator, io: std.Io) !Snapshot { const argv = [_][]const u8{ "git", "ls-files", "--cached", "-z" }; const result = try std.process.run(allocator, io, .{ .argv = &argv, .stderr_limit = .limited(child_stderr_max), .stdout_limit = .limited(child_stdout_max), .reserve_amount = child_stdout_max, .environ_map = null, .expand_arg0 = .no_expand, .progress_node = .none, .create_no_window = true, .disable_aslr = false, }); errdefer { allocator.free(result.stdout); allocator.free(result.stderr); } if (!successful(result.term)) return error.GitFailed; var entries = try allocator.alloc(Entry, tracked_paths_max); errdefer allocator.free(entries); var count: usize = 0; var start: usize = 0; while (start < result.stdout.len) { const end = std.mem.indexOfScalarPos(u8, result.stdout, start, 0) orelse { return error.InvalidGitOutput; }; if (end == start) return error.InvalidGitOutput; if (count == entries.len) return error.TooManyTrackedPaths; entries[count] = try observe(io, result.stdout[start..end]); count += 1; start = end + 1; } sortEntries(entries[0..count]); return .{ .entries = entries[0..count], .git_stdout = result.stdout, .git_stderr = result.stderr, }; } fn observe(io: std.Io, path: []const u8) !Entry { std.debug.assert(path.len > 0); std.debug.assert(path.len <= path_bytes_max); const cwd = std.Io.Dir.cwd(); const stat = cwd.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound, error.NotDir => return .{ .path = path, .kind = .missing, .size = 0, .digest = [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length, }, else => return err, }; switch (stat.kind) { .sym_link => { var target: [path_bytes_max]u8 = undefined; const target_len = try cwd.readLink(io, path, &target); if (target_len == target.len) return error.SymlinkTargetTooLong; return .{ .path = path, .kind = .sym_link, .size = target_len, .digest = digest(target[0..target_len]), }; }, .file => { var file = try cwd.openFile(io, path, .{ .mode = .read_only, .follow_symlinks = false, .allow_directory = false, }); defer file.close(io); const file_stat = try file.stat(io); return .{ .path = path, .kind = .file, .size = file_stat.size, .digest = try digestFile(file, io), }; }, else => return .{ .path = path, .kind = .other, .size = stat.size, .digest = [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length, }, } } fn digest(bytes: []const u8) Digest { var output: Digest = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &output, .{}); return output; } fn digestFile(file: std.Io.File, io: std.Io) !Digest { var hash = std.crypto.hash.sha2.Sha256.init(.{}); var reader_buffer: [io_buffer_bytes]u8 = undefined; var chunk: [io_buffer_bytes]u8 = undefined; var reader = file.reader(io, &reader_buffer); while (true) { const count = try reader.interface.readSliceShort(&chunk); if (count == 0) break; hash.update(chunk[0..count]); } var output: Digest = undefined; hash.final(&output); return output; } fn sortEntries(entries: []Entry) void { var index: usize = 1; while (index < entries.len) : (index += 1) { var position = index; while (position > 0 and std.mem.order(u8, entries[position - 1].path, entries[position].path) == .gt) { std.mem.swap(Entry, &entries[position - 1], &entries[position]); position -= 1; } } } fn sameEntry(before: Entry, after: Entry) bool { if (!std.mem.eql(u8, before.path, after.path)) return false; if (before.kind != after.kind) return false; if (before.size != after.size) return false; return std.mem.eql(u8, &before.digest, &after.digest); } fn compare(before: []const Entry, after: []const Entry, writer: *std.Io.Writer) !void { var before_index: usize = 0; var after_index: usize = 0; while (before_index < before.len or after_index < after.len) { if (before_index == before.len) { try reportDifference(writer, null, after[after_index]); return error.TrackedFilesChanged; } if (after_index == after.len) { try reportDifference(writer, before[before_index], null); return error.TrackedFilesChanged; } const order = std.mem.order(u8, before[before_index].path, after[after_index].path); if (order == .lt) { try reportDifference(writer, before[before_index], null); return error.TrackedFilesChanged; } if (order == .gt) { try reportDifference(writer, null, after[after_index]); return error.TrackedFilesChanged; } if (!sameEntry(before[before_index], after[after_index])) { try reportDifference(writer, before[before_index], after[after_index]); return error.TrackedFilesChanged; } before_index += 1; after_index += 1; } } fn reportDifference( writer: *std.Io.Writer, before: ?Entry, after: ?Entry, ) !void { try writer.writeAll("tracked-file immutability violation\n"); try writer.writeAll("before: "); try writeRecord(writer, before); try writer.writeAll("\nafter: "); try writeRecord(writer, after); try writer.writeByte('\n'); } fn writeRecord(writer: *std.Io.Writer, entry: ?Entry) !void { const value = entry orelse { try writer.writeAll("absent"); return; }; try writer.writeAll("path_hex="); try writeHex(writer, value.path); try writer.print(" path_length={d} kind={s} size={d} sha256=", .{ value.path.len, @tagName(value.kind), value.size, }); try writeHex(writer, &value.digest); } fn writeHex(writer: *std.Io.Writer, bytes: []const u8) !void { const digits = "0123456789abcdef"; for (bytes) |byte| { try writer.writeByte(digits[byte >> 4]); try writer.writeByte(digits[byte & 0x0f]); } } fn run_inner_build( allocator: std.mem.Allocator, io: std.Io, argv: []const []const u8, ) !std.process.RunResult { std.debug.assert(argv.len >= 2); return std.process.run(allocator, io, .{ .argv = argv, .stderr_limit = .limited(child_stderr_max), .stdout_limit = .limited(child_stdout_max), .reserve_amount = child_stderr_max, .environ_map = null, .expand_arg0 = .no_expand, .progress_node = .none, .create_no_window = true, .disable_aslr = false, }); } fn successful(term: std.process.Child.Term) bool { return switch (term) { .exited => |status| status == 0, else => false, }; } fn report_inner_failure(io: std.Io, result: std.process.RunResult) !void { var buffer: [io_buffer_bytes]u8 = undefined; var writer = std.Io.File.stderr().writer(io, &buffer); try writer.interface.writeAll("inner gate failed\n"); try writer.interface.writeAll(result.stdout); try writer.interface.writeAll(result.stderr); try writer.interface.flush(); } test "entry sorting uses raw path bytes" { var entries = [_]Entry{ .{ .path = "b", .kind = .file, .size = 1, .digest = [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length }, .{ .path = "a", .kind = .file, .size = 1, .digest = [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length }, .{ .path = "a\x80", .kind = .file, .size = 1, .digest = [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length }, }; sortEntries(&entries); try std.testing.expectEqualStrings("a", entries[0].path); try std.testing.expectEqualStrings("a\x80", entries[1].path); try std.testing.expectEqualStrings("b", entries[2].path); } test "unchanged dirty baseline compares equal" { const entry = Entry{ .path = "spec/TESTING.md", .kind = .file, .size = 7, .digest = digest("content"), }; var output = std.Io.Writer.Allocating.init(std.testing.allocator); defer output.deinit(); try compare(&.{entry}, &.{entry}, &output.writer); try std.testing.expectEqualStrings("", output.written()); } test "missing tracked file is a change" { const entry = Entry{ .path = "src/main.zig", .kind = .file, .size = 7, .digest = digest("content"), }; var output = std.Io.Writer.Allocating.init(std.testing.allocator); defer output.deinit(); try std.testing.expectError(error.TrackedFilesChanged, compare(&.{entry}, &.{}, &output.writer)); try std.testing.expect(std.mem.indexOf(u8, output.written(), "absent") != null); } test "added, changed, and renamed records are reported" { const before = [_]Entry{.{ .path = "old", .kind = .file, .size = 1, .digest = digest("a"), }}; const after = [_]Entry{.{ .path = "new", .kind = .file, .size = 1, .digest = digest("a"), }}; var output = std.Io.Writer.Allocating.init(std.testing.allocator); defer output.deinit(); try std.testing.expectError(error.TrackedFilesChanged, compare(&before, &after, &output.writer)); try std.testing.expect(std.mem.indexOf(u8, output.written(), "before:") != null); try std.testing.expect(std.mem.indexOf(u8, output.written(), "after:") != null); } test "kind changes are detected" { const before = Entry{ .path = "tracked", .kind = .file, .size = 1, .digest = digest("a"), }; const after = Entry{ .path = "tracked", .kind = .sym_link, .size = 1, .digest = digest("a"), }; var output = std.Io.Writer.Allocating.init(std.testing.allocator); defer output.deinit(); try std.testing.expectError(error.TrackedFilesChanged, compare(&.{before}, &.{after}, &output.writer)); } test "content changes are detected" { const before = Entry{ .path = "tracked", .kind = .file, .size = 1, .digest = digest("a"), }; const after = Entry{ .path = "tracked", .kind = .file, .size = 1, .digest = digest("b"), }; var output = std.Io.Writer.Allocating.init(std.testing.allocator); defer output.deinit(); try std.testing.expectError(error.TrackedFilesChanged, compare(&.{before}, &.{after}, &output.writer)); }