Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

tests/fixtures/test_exec_mutation_a.zig

Raw
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const spec_data = @import("spec_data");
const testing = std.testing;

/// Generic Linux terminal ioctls not carried by the standard library.
const TIOCSPTLCK: u32 = 0x40045431;
const TIOCGPTN: u32 = 0x80045430;
const TIOCSCTTY: u32 = 0x540E;

/// One terminal session against the built executable.
const Session = struct {
    output: []u8 = &.{},
    log_text: []u8 = &.{},
    leaf_existed: bool = true,
    exit_code: u32 = 255,
    signaled: bool = false,
    ok: bool = false,
};

pub const Fixture = struct {
    temporary: std.testing.TmpDir,
    executable: ?[:0]u8 = null,
    confirmed: Session = .{},
    drifted: Session = .{},
    ancestor_drifted: Session = .{},
    directory_confirmed: Session = .{},
    directory_drifted: Session = .{},
    /// Whether the symlink target outside the reviewed directory tree
    /// survived the confirmed directory session.
    outside_survived: bool = false,

    pub fn init() !Fixture {
        return .{ .temporary = testing.tmpDir(.{}) };
    }

    pub fn deinit(fixture: *Fixture) void {
        if (fixture.executable) |executable| testing.allocator.free(executable);
        if (fixture.confirmed.output.len > 0) testing.allocator.free(fixture.confirmed.output);
        if (fixture.confirmed.log_text.len > 0) testing.allocator.free(fixture.confirmed.log_text);
        if (fixture.drifted.output.len > 0) testing.allocator.free(fixture.drifted.output);
        if (fixture.drifted.log_text.len > 0) testing.allocator.free(fixture.drifted.log_text);
        if (fixture.ancestor_drifted.output.len > 0) testing.allocator.free(fixture.ancestor_drifted.output);
        if (fixture.ancestor_drifted.log_text.len > 0) testing.allocator.free(fixture.ancestor_drifted.log_text);
        if (fixture.directory_confirmed.output.len > 0) testing.allocator.free(fixture.directory_confirmed.output);
        if (fixture.directory_confirmed.log_text.len > 0) testing.allocator.free(fixture.directory_confirmed.log_text);
        if (fixture.directory_drifted.output.len > 0) testing.allocator.free(fixture.directory_drifted.output);
        if (fixture.directory_drifted.log_text.len > 0) testing.allocator.free(fixture.directory_drifted.log_text);
        fixture.temporary.cleanup();
        fixture.* = undefined;
    }

    pub fn given_program(fixture: *Fixture, executable_name: []const u8) !void {
        try testing.expect(fixture.executable == null);
        try testing.expectEqualStrings(spec_data.cli_executable, executable_name);
        fixture.executable = try std.Io.Dir.cwd().realPathFileAlloc(
            testing.io,
            "zig-out/bin/termux-janitor",
            testing.allocator,
        );
    }

    /// The per-session layout is created inside `when_run_sessions`; this
    /// given records that the tree under test contains nothing else.
    pub fn given_isolated_tree(fixture: *Fixture) !void {
        _ = fixture;
        try testing.expect(true);
    }

    pub fn when_run_sessions(fixture: *Fixture) !void {
        try testing.expect(fixture.executable != null);
        fixture.confirmed = try runSession(fixture, .flat);
        fixture.drifted = try runSession(fixture, .leaf_drift);
        fixture.ancestor_drifted = try runSession(fixture, .ancestor_drift);
        fixture.directory_confirmed = try runSession(fixture, .directory);
        fixture.directory_drifted = try runSession(fixture, .directory_drift);
    }

    pub fn then_inv_exec_mutation_a(fixture: *Fixture) !void {
        const session = fixture.confirmed;
        try testing.expect(session.ok);
        try testing.expect(!session.signaled);
        try testing.expectEqual(@as(u32, 0), session.exit_code);
        try testing.expect(std.mem.indexOf(u8, session.output, "panic") == null);
        try testing.expect(std.mem.indexOf(u8, session.output, "Segmentation") == null);
        try testing.expect(!session.leaf_existed);
        const intent = std.mem.indexOf(u8, session.log_text, "\"t\":\"intent\"").?;
        const leaf = std.mem.indexOf(u8, session.log_text, "a.tmp").?;
        const result = std.mem.indexOf(u8, session.log_text, "\"t\":\"result\",\"s\":\"success\"").?;
        try testing.expect(intent < leaf and leaf < result);
    }

    pub fn then_inv_exec_ancestor_a(fixture: *Fixture) !void {
        const session = fixture.ancestor_drifted;
        try testing.expect(session.ok);
        try testing.expect(session.leaf_existed);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"s\":\"success\"") == null);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"t\":\"result\",\"s\":\"failure\"") != null);
    }

    pub fn then_inv_exec_directory_a(fixture: *Fixture) !void {
        const session = fixture.directory_confirmed;
        try testing.expect(session.ok);
        try testing.expect(!session.signaled);
        try testing.expectEqual(@as(u32, 0), session.exit_code);
        try testing.expect(std.mem.indexOf(u8, session.output, "panic") == null);
        try testing.expect(std.mem.indexOf(u8, session.output, "Segmentation") == null);
        try testing.expect(!session.leaf_existed);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"t\":\"result\",\"s\":\"success\"") != null);
        // The symlink target outside the reviewed tree must survive.
        try testing.expect(fixture.outside_survived);
    }

    pub fn forbid_no_exec_directory_a(fixture: *Fixture) !void {
        const session = fixture.directory_drifted;
        try testing.expect(session.ok);
        try testing.expect(session.leaf_existed);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"s\":\"success\"") == null);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"t\":\"result\",\"s\":\"failure\"") != null);
    }

    pub fn forbid_no_exec_mutation_a(fixture: *Fixture) !void {
        const session = fixture.drifted;
        try testing.expect(session.ok);
        try testing.expect(session.leaf_existed);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"s\":\"success\"") == null);
        try testing.expect(std.mem.indexOf(u8, session.log_text, "\"t\":\"result\",\"s\":\"failure\"") != null);
    }
};

/// Session layout inside the temporary tree: one isolated home holding only
/// `a.tmp`, plus separate prefix and state directories, so the leaf sits on
/// the second checklist row and one down-arrow press focuses it.
/// Session shape: flat holds `a.tmp` directly in the isolated home; the
/// drift variants nest or rewrite as described by the fixture prose.
const Mode = enum { flat, leaf_drift, ancestor_drift, directory, directory_drift };

const Layout = struct {
    home: []const u8 = "",
    leaf: []const u8 = "",
    state: []const u8 = "",
    prefix: []const u8 = "",
    log: []const u8 = "",
    outside: []const u8 = "",
};

fn joinInto(buffer: []u8, parts: []const []const u8) []const u8 {
    var written: usize = 0;
    for (parts) |part| {
        @memcpy(buffer[written..][0..part.len], part);
        written += part.len;
    }
    return buffer[0..written];
}

fn layoutUnder(fixture: *Fixture, tag: []const u8, nested: bool) !Layout {
    var base: [std.fs.max_path_bytes]u8 = undefined;
    const base_len = try fixture.temporary.dir.realPath(testing.io, &base);
    const root = base[0..base_len];
    const leaf_suffix: []const u8 = if (nested) "/home/sub/a.tmp" else "/home/a.tmp";
    var home: [std.fs.max_path_bytes]u8 = undefined;
    var leaf: [std.fs.max_path_bytes]u8 = undefined;
    var state: [std.fs.max_path_bytes]u8 = undefined;
    var prefix: [std.fs.max_path_bytes]u8 = undefined;
    var log: [std.fs.max_path_bytes]u8 = undefined;
    var outside: [std.fs.max_path_bytes]u8 = undefined;
    return .{
        .home = try testing.allocator.dupe(u8, joinInto(&home, &.{ root, "/", tag, "/home" })),
        .leaf = try testing.allocator.dupe(u8, joinInto(&leaf, &.{ root, "/", tag, leaf_suffix })),
        .state = try testing.allocator.dupe(u8, joinInto(&state, &.{ root, "/", tag, "/state" })),
        .prefix = try testing.allocator.dupe(u8, joinInto(&prefix, &.{ root, "/", tag, "/prefix" })),
        .log = try testing.allocator.dupe(u8, joinInto(&log, &.{ root, "/", tag, "/state/termux-janitor/run.jsonl" })),
        .outside = try testing.allocator.dupe(u8, joinInto(&outside, &.{ root, "/", tag, "/home/outside" })),
    };
}

fn freeLayout(layout: *Layout) void {
    testing.allocator.free(layout.home);
    testing.allocator.free(layout.leaf);
    testing.allocator.free(layout.state);
    testing.allocator.free(layout.prefix);
    testing.allocator.free(layout.log);
    testing.allocator.free(layout.outside);
}

fn makeDirectory(path: []const u8) !void {
    std.Io.Dir.cwd().createDirPath(testing.io, path) catch |err| switch (err) {
        error.PathAlreadyExists => {},
        else => return err,
    };
}

fn writeFile(path: []const u8, bytes: []const u8) !void {
    const fd = try posix.openat(linux.AT.FDCWD, path, .{ .ACCMODE = .WRONLY, .CREAT = true }, 0o600);
    defer _ = linux.close(fd);
    var written: usize = 0;
    while (written < bytes.len) {
        const rc = linux.write(fd, bytes[written..].ptr, bytes.len - written);
        if (linux.errno(rc) != .SUCCESS) return error.WriteFailed;
        written += @intCast(rc);
    }
}

fn writeSymlink(path: []const u8, target: []const u8) !void {
    var path_buffer: [std.fs.max_path_bytes]u8 = undefined;
    var target_buffer: [std.fs.max_path_bytes]u8 = undefined;
    const path_z = std.fmt.bufPrintZ(&path_buffer, "{s}", .{path}) catch return error.PathTooLong;
    const target_z = std.fmt.bufPrintZ(&target_buffer, "{s}", .{target}) catch return error.PathTooLong;
    if (linux.errno(linux.symlink(target_z.ptr, path_z.ptr)) != .SUCCESS) return error.SymlinkFailed;
}

fn readWhole(path: []const u8, allocator: std.mem.Allocator) ![]u8 {
    const fd = posix.openat(linux.AT.FDCWD, path, .{ .ACCMODE = .RDONLY }, 0) catch
        return &.{};
    defer _ = linux.close(fd);
    var buffer: [4096]u8 = undefined;
    const read = try posix.read(fd, &buffer);
    return try allocator.dupe(u8, buffer[0..read]);
}

fn leafExists(path: []const u8) bool {
    const fd = posix.openat(linux.AT.FDCWD, path, .{ .ACCMODE = .RDONLY }, 0) catch
        return false;
    _ = linux.close(fd);
    return true;
}

/// Reads the pseudo-terminal master into `output` until `predicate` matches,
/// end of stream, or the time budget expires. An empty predicate drains.
fn pumpUntil(
    master_fd: posix.fd_t,
    output: *std.ArrayList(u8),
    predicate: []const u8,
    budget_ms: u32,
) !bool {
    var waited: u32 = 0;
    var scratch: [1024]u8 = undefined;
    while (waited < budget_ms) : (waited += 100) {
        if (predicate.len > 0 and std.mem.indexOf(u8, output.items, predicate) != null) return true;
        var poll_fds = [_]posix.pollfd{.{ .fd = master_fd, .events = posix.POLL.IN, .revents = 0 }};
        const ready = posix.poll(&poll_fds, 100) catch return false;
        if (ready == 0) continue;
        const read = posix.read(master_fd, &scratch) catch return false;
        if (read == 0) return false;
        try output.appendSlice(testing.allocator, scratch[0..read]);
    }
    return predicate.len == 0;
}

fn sendKeys(master_fd: posix.fd_t, keys: []const u8) void {
    _ = linux.write(master_fd, keys.ptr, keys.len);
}

fn milliSleep(ms: u32) void {
    const request = linux.timespec{ .sec = @intCast(ms / 1000), .nsec = @as(u32, ms % 1000) * std.time.ns_per_ms };
    _ = linux.nanosleep(&request, null);
}

fn removeDirectory(path: []const u8) void {
    var buffer: [std.fs.max_path_bytes]u8 = undefined;
    const z_path = std.fmt.bufPrintZ(&buffer, "{s}", .{path}) catch return;
    _ = linux.unlinkat(linux.AT.FDCWD, z_path.ptr, linux.AT.REMOVEDIR);
}

/// Swaps the reviewed ancestor for a freshly created directory holding the
/// identical leaf: only the directory identity changes, so a passing run
/// proves the ancestor identity compare, not a missing leaf.
fn swapAncestor(home: []const u8) !void {
    var sub: [std.fs.max_path_bytes]u8 = undefined;
    var moved: [std.fs.max_path_bytes]u8 = undefined;
    const sub_path = joinInto(&sub, &.{ home, "/sub" });
    const moved_path = joinInto(&moved, &.{ home, "/moved" });
    try std.Io.Dir.rename(std.Io.Dir.cwd(), sub_path, std.Io.Dir.cwd(), moved_path, testing.io);
    try makeDirectory(sub_path);
    var leaf: [std.fs.max_path_bytes]u8 = undefined;
    var new_leaf: [std.fs.max_path_bytes]u8 = undefined;
    const leaf_path = joinInto(&leaf, &.{ moved_path, "/a.tmp" });
    const new_leaf_path = joinInto(&new_leaf, &.{ sub_path, "/a.tmp" });
    try std.Io.Dir.rename(std.Io.Dir.cwd(), leaf_path, std.Io.Dir.cwd(), new_leaf_path, testing.io);
    removeDirectory(moved_path);
}

fn runSession(fixture: *Fixture, mode: Mode) !Session {
    const nested = mode != .flat;
    var layout = try layoutUnder(fixture, @tagName(mode), nested);
    defer freeLayout(&layout);
    try makeDirectory(layout.home);
    if (nested) {
        var sub: [std.fs.max_path_bytes]u8 = undefined;
        try makeDirectory(joinInto(&sub, &.{ layout.home, "/sub" }));
    }
    if (mode == .directory or mode == .directory_drift) {
        var empty: [std.fs.max_path_bytes]u8 = undefined;
        var link: [std.fs.max_path_bytes]u8 = undefined;
        try makeDirectory(joinInto(&empty, &.{ layout.home, "/sub/empty" }));
        try makeDirectory(layout.outside);
        try writeSymlink(joinInto(&link, &.{ layout.home, "/sub/link" }), "../outside");
    }
    try makeDirectory(layout.prefix);
    try makeDirectory(layout.state);
    try writeFile(layout.leaf, "temporary");

    const master_fd = try posix.openat(
        linux.AT.FDCWD,
        "/dev/ptmx",
        .{ .ACCMODE = .RDWR, .NOCTTY = true },
        0,
    );
    defer _ = linux.close(master_fd);
    var unlock: u32 = 0;
    _ = linux.ioctl(master_fd, TIOCSPTLCK, @intFromPtr(&unlock));
    var pt_number: u32 = 0;
    if (linux.errno(linux.ioctl(master_fd, TIOCGPTN, @intFromPtr(&pt_number))) != .SUCCESS) {
        return error.PtyUnavailable;
    }
    var slave_buffer: [32]u8 = undefined;
    const slave_path = try std.fmt.bufPrintZ(&slave_buffer, "/dev/pts/{d}", .{pt_number});

    var env_storage: [4][512]u8 = undefined;
    const home_entry = try std.fmt.bufPrintZ(&env_storage[0], "HOME={s}", .{layout.home});
    const prefix_entry = try std.fmt.bufPrintZ(&env_storage[1], "PREFIX={s}", .{layout.prefix});
    const state_entry = try std.fmt.bufPrintZ(&env_storage[2], "XDG_STATE_HOME={s}", .{layout.state});
    const term_entry = try std.fmt.bufPrintZ(&env_storage[3], "TERM=xterm", .{});
    const argv = [_:null]?[*:0]const u8{ "termux-janitor", "--ascii" };
    const envp = [_:null]?[*:0]const u8{ home_entry.ptr, prefix_entry.ptr, state_entry.ptr, term_entry.ptr };

    const fork_rc = linux.fork();
    if (linux.errno(fork_rc) != .SUCCESS) return error.ForkFailed;
    if (fork_rc == 0) {
        _ = linux.setsid();
        const slave_open = posix.openat(
            linux.AT.FDCWD,
            slave_path,
            .{ .ACCMODE = .RDWR },
            0,
        ) catch linux.exit_group(126);
        _ = linux.ioctl(slave_open, TIOCSCTTY, 0);
        _ = linux.dup2(slave_open, 0);
        _ = linux.dup2(slave_open, 1);
        _ = linux.dup2(slave_open, 2);
        if (slave_open > 2) _ = linux.close(slave_open);
        _ = linux.close(master_fd);
        _ = linux.execve(fixture.executable.?, &argv, &envp);
        linux.exit_group(127);
    }

    var session: Session = .{};
    var output: std.ArrayList(u8) = .empty;
    defer output.deinit(testing.allocator);

    session.ok = try pumpUntil(master_fd, &output, "\x1b[?1049h", 15_000);
    milliSleep(300);
    // Focus the target deterministically through search so the canonical
    // checklist ordering never affects which row the keys select.
    const target: []const u8 = if (mode == .directory or mode == .directory_drift) "sub" else "a.tmp";
    sendKeys(master_fd, "/");
    milliSleep(200);
    sendKeys(master_fd, target);
    milliSleep(200);
    sendKeys(master_fd, "\r");
    milliSleep(300);
    switch (mode) {
        .flat => {},
        .leaf_drift => try writeFile(layout.leaf, "drifted"),
        .ancestor_drift => try swapAncestor(layout.home),
        .directory => {},
        .directory_drift => {
            // Replace one reviewed child with a same-name successor so only
            // its identity differs; the child-set check must refuse the
            // whole directory action.
            var buffer: [std.fs.max_path_bytes]u8 = undefined;
            const leaf_z = std.fmt.bufPrintZ(&buffer, "{s}", .{layout.leaf}) catch return error.PathTooLong;
            _ = linux.unlinkat(linux.AT.FDCWD, leaf_z.ptr, 0);
            try writeFile(layout.leaf, "replaced");
        },
    }
    milliSleep(150);
    sendKeys(master_fd, " ");
    milliSleep(150);
    sendKeys(master_fd, "c");
    milliSleep(150);
    sendKeys(master_fd, "CONFIRM");
    milliSleep(150);
    sendKeys(master_fd, "\r");
    _ = try pumpUntil(master_fd, &output, "", 3_000);
    sendKeys(master_fd, "q");
    _ = try pumpUntil(master_fd, &output, "", 3_000);

    var status: u32 = 0;
    _ = linux.wait4(@intCast(fork_rc), &status, 0, null);
    session.exit_code = (status >> 8) & 0xFF;
    session.signaled = (status & 0x7F) != 0;
    session.output = try testing.allocator.dupe(u8, output.items);
    session.log_text = try readWhole(layout.log, testing.allocator);
    session.leaf_existed = leafExists(layout.leaf);
    if (mode == .directory) fixture.outside_survived = leafExists(layout.outside);
    return session;
}