repositories / termux-janitor
termux-janitor
Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.
owned by admin
tools/janitor/gate.zig
Rawconst 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));
}