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