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