Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

tools/janitor/gate.zig

Raw
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));
}