Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

src/plan.zig

Raw
//! Planning and execution owned by `spec/PRODUCT.md` sections 10 and 11
//! (`TJ-PLAN-01` through `TJ-PLAN-10`, `TJ-EXEC-01` through `TJ-EXEC-05`).
//! Selection becomes a deterministic, immutable plan: ordered actions with
//! reviewed manifests, descriptor-relative revalidation immediately before
//! every `unlinkat`, and no path that bypasses the reviewed plan.
//!
//! Spine increment: direct filesystem actions for regular files, symlinks,
//! and empty directories. A selected non-empty directory builds a complete
//! reviewed manifest and stays reviewable; its action is unavailable until a
//! later increment implements descendant manifests.
const std = @import("std");
const linux = std.os.linux;
const spec_data = @import("spec_data");
const scan_mod = @import("scan.zig");
const runlog_mod = @import("runlog.zig");

pub const actions_max: u32 = @intCast(spec_data.limit_value.plan_actions);

/// One reviewed leaf: the exact identity that revalidation compares before
/// unlinking (`TJ-PLAN-06`, `TJ-EXEC-01`).
pub const ManifestEntry = struct {
    ino: u64,
    dev_major: u32,
    dev_minor: u32,
    mnt_id: u64,
    kind: scan_mod.Kind,
    nlink: u64,
    size: u64,
    mtime_sec: i64,
    mtime_nsec: u32,
};

pub const Action = struct {
    finding: u32,
    leaf: ManifestEntry = .{
        .ino = 0,
        .dev_major = 0,
        .dev_minor = 0,
        .mnt_id = 0,
        .kind = .file,
        .nlink = 0,
        .size = 0,
        .mtime_sec = 0,
        .mtime_nsec = 0,
    },
    /// False when the reviewed manifest could not be completed; an
    /// incomplete manifest stays reviewable but unconfirmable
    /// (`TJ-PLAN-06`).
    complete: bool = true,
    unavailable_reason: []const u8 = "",
    estimate: u64 = 0,
    estimate_known: bool = false,
    /// Selected scan root the finding was retained under; execution reopens
    /// this root and revalidates ancestry below it (`TJ-EXEC-02`).
    root_index: u32 = 0,
    /// Reviewed descendant manifest slice for a directory action
    /// (`TJ-PLAN-06`); empty for file and symlink actions.
    manifest_offset: u32 = 0,
    manifest_len: u32 = 0,
};

/// One immutable plan owned by one scan generation (`TJ-PLAN-01`).
pub const Plan = struct {
    actions: [actions_max]Action = undefined,
    actions_len: u32 = 0,

    pub fn confirmable(self: *const Plan) bool {
        if (self.actions_len == 0) return false;
        var index: u32 = 0;
        while (index < self.actions_len) : (index += 1) {
            if (!self.actions[index].complete) return false;
        }
        return true;
    }

    pub fn knownEstimate(self: *const Plan) ?u64 {
        var total: u64 = 0;
        var index: u32 = 0;
        while (index < self.actions_len) : (index += 1) {
            if (!self.actions[index].estimate_known) return null;
            total += self.actions[index].estimate;
        }
        return total;
    }
};

/// Collects the reviewed descendant manifest for one individually selected
/// directory: every retained descendant of the immutable scan generation,
/// ordered deepest-first with raw name bytes ascending so children always
/// precede their parents at execution (`TJ-EXEC-04`). Capacity exhaustion or
/// an unsupported entry kind leaves the action reviewable but unconfirmable.
fn buildManifest(scan: *scan_mod.Scan, directory: u32, action: *Action) void {
    if (manifest_pool_used >= manifest_pool.len) {
        action.complete = false;
        action.unavailable_reason = "manifest capacity exhausted";
        return;
    }
    var member: [scan_mod.retained_findings_max / 8 + 1]u8 = undefined;
    @memset(&member, 0);
    // Membership by upward parent walk: every finding whose chain reaches
    // the selected directory descends from the same immutable generation.
    var index: u32 = 0;
    while (index < scan.findings_len) : (index += 1) {
        if (index == directory) continue;
        var cursor = scan.findings[index].parent;
        var depth_guard: u32 = 0;
        while (cursor != scan_mod.no_parent and depth_guard < scan_mod.traversal_depth_max) : (depth_guard += 1) {
            if (cursor == directory) {
                member[index / 8] |= @as(u8, 1) << @intCast(index % 8);
                break;
            }
            cursor = scan.findings[cursor].parent;
        }
    }
    var total: u32 = 1;
    index = 0;
    while (index < scan.findings_len) : (index += 1) {
        const bit = member[index / 8] & (@as(u8, 1) << @intCast(index % 8));
        if (bit == 0) continue;
        if (total >= manifest_pool.len) {
            action.complete = false;
            action.unavailable_reason = "manifest capacity exhausted";
            return;
        }
        manifest_pool[total] = .{ .finding = index, .parent = index };
        total += 1;
    }
    const entries = manifest_pool[0..total];
    entries[0] = .{ .finding = directory, .parent = 0 };
    const Context = struct {
        scan: *scan_mod.Scan,
        directory: u32,
        fn lessThan(ctx: @This(), a: ManifestRef, b: ManifestRef) bool {
            // The selected directory anchors index zero; every other entry
            // follows deepest-first with raw name bytes ascending, so a
            // forward walk mutates children before their parents.
            const a_root = a.finding == ctx.directory;
            const b_root = b.finding == ctx.directory;
            if (a_root != b_root) return a_root;
            const first = ctx.scan.findings[a.finding];
            const second = ctx.scan.findings[b.finding];
            if (first.depth != second.depth) return first.depth > second.depth;
            return std.mem.order(u8, first.name, second.name) == .lt;
        }
    };
    std.mem.sort(ManifestRef, entries, Context{ .scan = scan, .directory = directory }, Context.lessThan);
    for (entries, 0..) |entry, position| manifest_index_of[entry.finding] = @intCast(position);
    for (entries) |*entry| {
        if (entry.finding == directory) {
            entry.parent = 0;
        } else {
            entry.parent = manifest_index_of[scan.findings[entry.finding].parent];
        }
    }
    for (entries) |entry| {
        if (scan.findings[entry.finding].kind == .other) {
            action.complete = false;
            action.unavailable_reason = "unsupported entry type in directory manifest";
            return;
        }
    }
    action.manifest_offset = manifest_pool_used;
    action.manifest_len = total;
    manifest_pool_used += total;
}

fn leafEntry(finding: *const scan_mod.Finding) ManifestEntry {
    return .{
        .ino = finding.ino,
        .dev_major = finding.dev_major,
        .dev_minor = finding.dev_minor,
        .mnt_id = finding.mnt_id,
        .kind = finding.kind,
        .nlink = finding.nlink,
        .size = finding.apparent_size,
        .mtime_sec = finding.mtime_sec,
        .mtime_nsec = finding.mtime_nsec,
    };
}

/// One reviewed descendant entry of a directory action, referencing the
/// immutable scan generation (`TJ-PLAN-06`): names, identity, and parent
/// structure are owned by the finding and never duplicated. The parent
/// field is the manifest-local index of the parent entry; the selected
/// directory itself is entry zero and points at itself.
pub const ManifestRef = struct {
    finding: u32,
    parent: u32,
};

pub const manifest_entries_per_action: u32 =
    @intCast(spec_data.limit_value.manifest_entries_per_action);

/// Shared reviewed-manifest pool for one plan build. Actions reference
/// disjoint slices; exhaustion leaves a later directory action reviewable
/// but unconfirmable (`TJ-PLAN-06`).
var manifest_pool: [manifest_entries_per_action]ManifestRef = undefined;
var manifest_pool_used: u32 = 0;
/// Manifest-local entry index per finding for the current build.
var manifest_index_of: [scan_mod.retained_findings_max]u32 = undefined;

/// Builds the plan from the scan's selected findings. Canonical order is
/// deepest-first, then raw name bytes ascending (`TJ-PLAN-02`).
pub fn build(scan: *scan_mod.Scan) Plan {
    manifest_pool_used = 0;
    effect_parents_len = 0;
    effect_dir_parents_len = 0;
    var plan: Plan = .{};
    var order: [actions_max]u32 = undefined;
    var selected_count: u32 = 0;
    var index: u32 = 0;
    while (index < scan.findings_len) : (index += 1) {
        const finding = scan.findings[index];
        if (!finding.selected) continue;
        if (finding.kind != .file and finding.kind != .symlink and finding.kind != .directory) continue;
        if (selected_count >= actions_max) break;
        order[selected_count] = index;
        selected_count += 1;
    }
    const Context = struct {
        scan: *scan_mod.Scan,
        fn lessThan(ctx: @This(), a: u32, b: u32) bool {
            if (ctx.scan.findings[a].depth != ctx.scan.findings[b].depth) {
                return ctx.scan.findings[a].depth > ctx.scan.findings[b].depth;
            }
            return std.mem.order(u8, ctx.scan.findings[a].name, ctx.scan.findings[b].name) == .lt;
        }
    };
    std.mem.sort(u32, order[0..selected_count], Context{ .scan = scan }, Context.lessThan);
    for (order[0..selected_count]) |finding_index| {
        const finding = scan.findings[finding_index];
        var action: Action = .{
            .finding = finding_index,
            .leaf = leafEntry(&finding),
            .estimate = finding.allocated_bytes,
            .estimate_known = finding.blocks_known and finding.allocated_bytes != scan_mod.unknown_size,
            .root_index = finding.root_index,
        };
        if (finding.kind == .directory) {
            buildManifest(scan, finding_index, &action);
        }
        plan.actions[plan.actions_len] = action;
        plan.actions_len += 1;
    }
    return plan;
}

pub const Confirm = struct {
    /// Bounded phrase input: seven phrase bytes, an eighth printable byte
    /// clears it; Enter accepts only the exact phrase; Backspace edits
    /// (`TJ-PLAN-10`).
    bytes: [7]u8 = undefined,
    len: u32 = 0,

    pub fn text(self: *const Confirm) []const u8 {
        return self.bytes[0..self.len];
    }

    pub const Feed = enum { accepted, cleared, more };

    pub fn feed(self: *Confirm, byte: u8) Feed {
        if (byte == '\r' or byte == '\n') {
            if (self.len == 7 and std.mem.eql(u8, self.bytes[0..7], "CONFIRM")) {
                self.len = 0;
                return .accepted;
            }
            self.len = 0;
            return .cleared;
        }
        if (byte == 0x7F or byte == 0x08) {
            if (self.len > 0) self.len -= 1;
            return .more;
        }
        if (byte < 0x20 or byte > 0x7E) return .more;
        if (self.len >= 7) {
            self.len = 0;
            return .cleared;
        }
        self.bytes[self.len] = byte;
        self.len += 1;
        return .more;
    }
};

pub const Outcome = enum { success, failure };

/// Executes one action with the complete immediate revalidation sequence of
/// `TJ-EXEC-02`: every ancestor reopened descriptor-relative with no-follow
/// semantics and validated before descending, the leaf compared against the
/// reviewed manifest, then one adjacent `unlinkat`. Any mismatch fails the
/// action without mutating. The intent frame is appended and flushed before
/// admission; the result frame after (`TJ-LOG-05`).
/// Frames are limit-sized (`log_payload_bytes`) and must never live on the
/// stack: a single frame exceeds the process stack budget. Execution is
/// single-threaded and strictly sequential, so module storage is safe and
/// statically allocated (`CODING_STYLE.md`).
var intent_frame: runlog_mod.Frame = .{};
var result_frame: runlog_mod.Frame = .{};

pub fn executeAction(
    paths: [][]const u8,
    roots: []const []const u8,
    scan: *const scan_mod.Scan,
    action: *const Action,
    log: *runlog_mod.RunLog,
) Outcome {
    const path = paths[action.finding];
    if (action.root_index >= roots.len) return .failure;
    intent_frame = .{};
    if (!appendEscaped(&intent_frame, "intent", path, log)) return .failure;
    const ok = mutate(action, roots[action.root_index], path, scan);
    result_frame = .{};
    // The payload is formatted into separate scratch: `renderFrame` copies
    // the payload into the frame, so the two must never alias.
    var payload_buffer: [64]u8 = undefined;
    const payload = std.fmt.bufPrint(
        &payload_buffer,
        "{{\"t\":\"result\",\"s\":\"{s}\"}}",
        .{if (ok) "success" else "failure"},
    ) catch return .failure;
    if (!runlog_mod.renderFrame(payload, &result_frame) or !log.append(&result_frame)) {
        // A post-mutation logging failure marks the audit uncertain and
        // disables further mutation (`TJ-LOG-06`); the caller observes the
        // log state.
        return .failure;
    }
    return if (ok) .success else .failure;
}

fn appendEscaped(frame: *runlog_mod.Frame, kind: []const u8, path: []const u8, log: *runlog_mod.RunLog) bool {
    var escaped: [runlog_mod.escaped_value_bytes_max]u8 = undefined;
    const escaped_path = runlog_mod.escapeInto(path, &escaped) orelse return false;
    // The payload is fixed keys plus exactly one escaped value, so a
    // right-sized stack scratch bound by `escaped_value_bytes_max` always
    // suffices; `bufPrint` fails closed on overflow.
    var payload_buffer: [runlog_mod.escaped_value_bytes_max + 32]u8 = undefined;
    const payload = std.fmt.bufPrint(
        &payload_buffer,
        "{{\"t\":\"{s}\",\"p\":\"{s}\"}}",
        .{ kind, escaped_path },
    ) catch return false;
    if (!runlog_mod.renderFrame(payload, frame)) return false;
    return log.append(frame);
}

fn identityMatches(stat: *const linux.Statx, leaf: *const ManifestEntry) bool {
    return stat.ino == leaf.ino and
        stat.dev_major == leaf.dev_major and
        stat.dev_minor == leaf.dev_minor and
        stat.mnt_id == leaf.mnt_id and
        (scan_mod.kindOf(stat) orelse .other) == leaf.kind and
        stat.nlink == leaf.nlink and
        stat.size == leaf.size and
        stat.mtime.sec == leaf.mtime_sec and
        stat.mtime.nsec == leaf.mtime_nsec;
}

/// Full identity compare of one live entry against its reviewed finding
/// (`TJ-EXEC-01`): type, inode, device, mount ID, and link count are strict;
/// size and modification timestamp relax only under an exactly recorded
/// earlier-action effect, including effects of the directory action in
/// progress.
fn identityMatchesFinding(
    stat: *const linux.Statx,
    record: *const scan_mod.Finding,
    finding: u32,
) bool {
    if ((scan_mod.kindOf(stat) orelse .other) != record.kind) return false;
    if (stat.ino != record.ino) return false;
    if (stat.dev_major != record.dev_major) return false;
    if (stat.dev_minor != record.dev_minor) return false;
    if (stat.mnt_id != record.mnt_id) return false;
    // Link count carries exactly the recorded removal of reviewed
    // subdirectories; every other difference is drift (`TJ-EXEC-01`).
    const removed_subdirs = removedSubdirectories(finding);
    if (stat.nlink + removed_subdirs != record.nlink) return false;
    if (!effectTouches(finding)) {
        if (stat.size != record.apparent_size) return false;
        if (stat.mtime.sec != record.mtime_sec) return false;
        if (stat.mtime.nsec != record.mtime_nsec) return false;
    }
    return true;
}

/// Exactly recorded earlier-action effects for the current plan: the parent
/// of every removed entry, and separately the parent of every removed
/// directory, whose link count changed (`TJ-EXEC-05`). Recording is capped;
/// overflow leaves later checks strict, which fails closed.
var effect_parents: [manifest_entries_per_action]u32 = undefined;
var effect_parents_len: u32 = 0;
var effect_dir_parents: [manifest_entries_per_action]u32 = undefined;
var effect_dir_parents_len: u32 = 0;

fn recordEffect(parent: u32, removed_directory: bool) void {
    if (effect_parents_len < effect_parents.len) {
        effect_parents[effect_parents_len] = parent;
        effect_parents_len += 1;
    }
    if (removed_directory and effect_dir_parents_len < effect_dir_parents.len) {
        effect_dir_parents[effect_dir_parents_len] = parent;
        effect_dir_parents_len += 1;
    }
}

fn effectTouches(finding: u32) bool {
    var index: u32 = 0;
    while (index < effect_parents_len) : (index += 1) {
        if (effect_parents[index] == finding) return true;
    }
    return false;
}

fn removedSubdirectories(finding: u32) u32 {
    var total: u32 = 0;
    var index: u32 = 0;
    while (index < effect_dir_parents_len) : (index += 1) {
        if (effect_dir_parents[index] == finding) total += 1;
    }
    return total;
}

fn splitComponents(path: []const u8, components: [][]const u8) ?usize {
    var count: usize = 0;
    var rest = path;
    while (rest.len > 0) {
        const slash = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len;
        const component = rest[0..slash];
        rest = if (slash < rest.len) rest[slash + 1 ..] else "";
        if (component.len == 0) continue;
        if (count >= components.len) return null;
        components[count] = component;
        count += 1;
    }
    return count;
}

/// Reopens the selected root, revalidates it and every reviewed ancestor of
/// one manifest entry descriptor-relative with no-follow semantics, and
/// returns the parent descriptor of that entry (`TJ-EXEC-02` steps 1-3).
fn openEntryParent(
    root_path: []const u8,
    scan: *const scan_mod.Scan,
    root_finding: u32,
    entry_finding: u32,
) ?std.posix.fd_t {
    var components: [scan_mod.traversal_depth_max][]const u8 = undefined;
    var path_buffer: [scan_mod.raw_path_bytes_max]u8 = undefined;
    const path = scan.pathOf(entry_finding, &path_buffer);
    const count = splitComponents(path, &components) orelse return null;
    var root_components: [scan_mod.traversal_depth_max][]const u8 = undefined;
    const root_count = splitComponents(root_path, &root_components) orelse return null;
    if (root_count == 0 or count <= root_count) return null;
    for (root_components[0..root_count], components[0..root_count]) |r, p| {
        if (!std.mem.eql(u8, r, p)) return null;
    }
    var dir_fd = std.posix.openat(
        std.posix.AT.FDCWD,
        root_path,
        .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
        0,
    ) catch return null;
    {
        const root_stat = scan_mod.statxFd(dir_fd) catch {
            _ = linux.close(dir_fd);
            return null;
        };
        if (!identityMatchesFinding(&root_stat, &scan.findings[root_finding], root_finding)) {
            _ = linux.close(dir_fd);
            return null;
        }
    }
    const ancestor_total = count - root_count - 1;
    var chain: [scan_mod.traversal_depth_max]u32 = undefined;
    {
        var cursor = scan.findings[entry_finding].parent;
        var collected: usize = 0;
        while (collected < ancestor_total) {
            if (cursor == scan_mod.no_parent or collected >= chain.len) {
                _ = linux.close(dir_fd);
                return null;
            }
            chain[collected] = cursor;
            collected += 1;
            cursor = scan.findings[cursor].parent;
        }
    }
    var level: usize = root_count;
    while (level + 1 < count) : (level += 1) {
        @memcpy(scan_mod.name_nul[0..components[level].len], components[level]);
        scan_mod.name_nul[components[level].len] = 0;
        const stat = scan_mod.statxAt(dir_fd, &scan_mod.name_nul) catch {
            _ = linux.close(dir_fd);
            return null;
        };
        const reviewed = chain[ancestor_total - 1 - (level - root_count)];
        if (!identityMatchesFinding(&stat, &scan.findings[reviewed], reviewed)) {
            _ = linux.close(dir_fd);
            return null;
        }
        const next_fd = std.posix.openat(
            dir_fd,
            scan_mod.name_nul[0..components[level].len :0],
            .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
            0,
        ) catch {
            _ = linux.close(dir_fd);
            return null;
        };
        _ = linux.close(dir_fd);
        dir_fd = next_fd;
    }
    return dir_fd;
}

/// Checks the complete live child set of every manifest directory before
/// any mutation (`TJ-EXEC-03`, `TJ-EXEC-04`): each live child must match one
/// reviewed entry by raw name and full identity, no reviewed child may be
/// missing, and no unreviewed child may exist. Descriptor-relative,
/// no-follow, bounded by the traversal depth.
fn validateSubtree(
    directory_fd: std.posix.fd_t,
    entries: []const ManifestRef,
    scan: *const scan_mod.Scan,
) bool {
    const io = std.Io.Threaded.global_single_threaded.io();
    const Frame = struct { fd: std.posix.fd_t, entry: u32 };
    var stack: [scan_mod.traversal_depth_max]Frame = undefined;
    var matched: [manifest_entries_per_action / 8 + 1]u8 = undefined;
    @memset(&matched, 0);
    stack[0] = .{ .fd = directory_fd, .entry = 0 };
    var top: usize = 1;
    while (top > 0) {
        top -= 1;
        const frame = stack[top];
        defer _ = linux.close(frame.fd);
        var expected_children: u32 = 0;
        for (entries, 0..) |entry, index| {
            if (entry.parent != frame.entry) continue;
            if (frame.entry == 0 and index == 0) continue;
            expected_children += 1;
        }
        var matched_children: u32 = 0;
        var iterator = std.Io.Dir.iterate(std.Io.Dir{ .handle = frame.fd });
        while (iterator.next(io) catch return false) |live| {
            if (live.name.len > scan_mod.raw_component_bytes_max) return false;
            var found: ?u32 = null;
            for (entries, 0..) |entry, index| {
                if (entry.parent != frame.entry) continue;
                if (frame.entry == 0 and index == 0) continue;
                if (std.mem.eql(u8, scan.findings[entry.finding].name, live.name)) {
                    found = @intCast(index);
                    break;
                }
            }
            const child = found orelse {
                return false;
            };
            if (matched[child / 8] & (@as(u8, 1) << @intCast(child % 8)) != 0) return false;
            var name_buffer: [scan_mod.raw_component_bytes_max + 1]u8 = undefined;
            @memcpy(name_buffer[0..live.name.len], live.name);
            name_buffer[live.name.len] = 0;
            const child_stat = scan_mod.statxAt(frame.fd, @ptrCast(&name_buffer)) catch return false;
            const child_finding = entries[child].finding;
            const relax_parent = scan.findings[child_finding].parent;
            if (!identityMatchesFinding(&child_stat, &scan.findings[child_finding], relax_parent)) {
                return false;
            }
            matched[child / 8] |= @as(u8, 1) << @intCast(child % 8);
            matched_children += 1;
            if (scan.findings[child_finding].kind == .directory) {
                const child_fd = std.posix.openat(
                    frame.fd,
                    name_buffer[0..live.name.len :0],
                    .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
                    0,
                ) catch return false;
                if (top >= stack.len) {
                    _ = linux.close(child_fd);
                    return false;
                }
                stack[top] = .{ .fd = child_fd, .entry = child };
                top += 1;
            }
        }
        if (matched_children != expected_children) {
            return false;
        }
    }
    return true;
}

fn basename(path: []const u8) []const u8 {
    const slash = std.mem.lastIndexOfScalar(u8, path, '/') orelse return path;
    return path[slash + 1 ..];
}

/// Mutates the reviewed manifest deepest-first, each entry preceded by its
/// own reopened ancestry and adjacent final check, the selected directory
/// last (`TJ-EXEC-04`). Every removal records its parent for the expected
/// earlier-action relaxation of later checks.
fn executeManifest(
    action: *const Action,
    root_path: []const u8,
    entries: []const ManifestRef,
    scan: *const scan_mod.Scan,
) bool {
    const root_finding = scan.root_findings[action.root_index];
    var position: u32 = 1;
    while (position < entries.len) : (position += 1) {
        const entry = entries[position];
        if (!mutateManifestEntry(root_path, scan, root_finding, entry.finding)) return false;
    }
    return mutateManifestEntry(root_path, scan, root_finding, entries[0].finding);
}

fn mutateManifestEntry(
    root_path: []const u8,
    scan: *const scan_mod.Scan,
    root_finding: u32,
    entry_finding: u32,
) bool {
    const parent_fd = openEntryParent(root_path, scan, root_finding, entry_finding) orelse {
        return false;
    };
    defer _ = linux.close(parent_fd);
    var path_buffer: [scan_mod.raw_path_bytes_max]u8 = undefined;
    const path = scan.pathOf(entry_finding, &path_buffer);
    const name = basename(path);
    @memcpy(scan_mod.name_nul[0..name.len], name);
    scan_mod.name_nul[name.len] = 0;
    const stat = scan_mod.statxAt(parent_fd, &scan_mod.name_nul) catch return false;
    if (!identityMatchesFinding(&stat, &scan.findings[entry_finding], entry_finding)) {
        return false;
    }
    const flags: u32 = if (scan.findings[entry_finding].kind == .directory) linux.AT.REMOVEDIR else 0;
    const rc = linux.unlinkat(parent_fd, &scan_mod.name_nul, flags);
    if (linux.errno(rc) != .SUCCESS) return false;
    recordEffect(
        scan.findings[entry_finding].parent,
        scan.findings[entry_finding].kind == .directory,
    );
    return true;
}

fn mutate(
    action: *const Action,
    root_path: []const u8,
    path: []const u8,
    scan: *const scan_mod.Scan,
) bool {
    if (!action.complete) return false;
    var components: [scan_mod.traversal_depth_max][]const u8 = undefined;
    const count = splitComponents(path, &components) orelse return false;
    var root_components: [scan_mod.traversal_depth_max][]const u8 = undefined;
    const root_count = splitComponents(root_path, &root_components) orelse return false;
    if (root_count == 0 or count <= root_count) return false; // never mutate a root itself
    for (root_components[0..root_count], components[0..root_count]) |r, p| {
        if (!std.mem.eql(u8, r, p)) return false;
    }
    // Reopen the selected root by path, then every reviewed ancestor below
    // it descriptor-relative with no-follow semantics, validating each
    // before descending (`TJ-EXEC-02` steps 1-3). Android application
    // domains cannot open `/`; the configured root is the traversal anchor
    // and is always openable because the scan opened it.
    var dir_fd = std.posix.openat(
        std.posix.AT.FDCWD,
        root_path,
        .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
        0,
    ) catch return false;
    defer _ = std.os.linux.close(dir_fd);
    // Reopen and validate the selected root itself against its reviewed
    // identity (`TJ-EXEC-02` step 1). A root whose metadata changed since the
    // review fails closed unless the change is exactly a recorded earlier
    // action effect.
    if (action.root_index >= scan.root_findings_len) return false;
    const root_finding = scan.root_findings[action.root_index];
    {
        const root_stat = scan_mod.statxFd(dir_fd) catch return false;
        if (!identityMatchesFinding(&root_stat, &scan.findings[root_finding], root_finding)) return false;
    }
    // Collect the reviewed ancestor chain, leaf up to root, bounded by the
    // registered traversal depth (`TJ-EXEC-01`).
    const ancestor_total = count - root_count - 1;
    var chain: [scan_mod.traversal_depth_max]u32 = undefined;
    {
        var cursor = scan.findings[action.finding].parent;
        var collected: usize = 0;
        while (collected < ancestor_total) {
            if (cursor == scan_mod.no_parent or collected >= chain.len) return false;
            chain[collected] = cursor;
            collected += 1;
            cursor = scan.findings[cursor].parent;
        }
    }
    var level: usize = root_count;
    while (level + 1 < count) : (level += 1) {
        @memcpy(scan_mod.name_nul[0..components[level].len], components[level]);
        scan_mod.name_nul[components[level].len] = 0;
        const stat = scan_mod.statxAt(dir_fd, &scan_mod.name_nul) catch return false;
        const reviewed_index = chain[ancestor_total - 1 - (level - root_count)];
        if (!identityMatchesFinding(&stat, &scan.findings[reviewed_index], reviewed_index)) return false;
        const next_fd = std.posix.openat(
            dir_fd,
            scan_mod.name_nul[0..components[level].len :0],
            .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
            0,
        ) catch return false;
        _ = std.os.linux.close(dir_fd);
        dir_fd = next_fd;
    }
    const leaf = components[count - 1];
    @memcpy(scan_mod.name_nul[0..leaf.len], leaf);
    scan_mod.name_nul[leaf.len] = 0;
    const stat = scan_mod.statxAt(dir_fd, &scan_mod.name_nul) catch return false;
    if (!identityMatchesFinding(&stat, &scan.findings[action.finding], action.finding)) return false;
    if (action.leaf.kind != .directory) {
        const rc = linux.unlinkat(dir_fd, &scan_mod.name_nul, 0);
        if (linux.errno(rc) != .SUCCESS) return false;
        recordEffect(scan.findings[action.finding].parent, false);
        return true;
    }
    // Directory action (`TJ-EXEC-03`): the complete child set is checked
    // before any mutation, then manifest entries mutate deepest-first with
    // their own adjacent checks, and the selected directory goes last.
    if (action.manifest_len == 0) {
        const rc = linux.unlinkat(dir_fd, &scan_mod.name_nul, linux.AT.REMOVEDIR);
        if (linux.errno(rc) != .SUCCESS) return false;
        recordEffect(scan.findings[action.finding].parent, true);
        return true;
    }
    const entries = manifest_pool[action.manifest_offset..][0..action.manifest_len];
    const directory_fd = std.posix.openat(
        dir_fd,
        scan_mod.name_nul[0..leaf.len :0],
        .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
        0,
    ) catch return false;
    defer _ = linux.close(directory_fd);
    if (!validateSubtree(directory_fd, entries, scan)) return false;
    const removed = executeManifest(action, root_path, entries, scan);
    if (removed) recordEffect(scan.findings[action.finding].parent, true);
    return removed;
}

comptime {
    std.debug.assert(actions_max > 0);
    var confirm: Confirm = .{};
    _ = confirm.feed('C');
    _ = confirm.feed('O');
    _ = confirm.feed('N');
    _ = confirm.feed('F');
    _ = confirm.feed('I');
    _ = confirm.feed('R');
    _ = confirm.feed('M');
    std.debug.assert(confirm.feed('\r') == .accepted);
    std.debug.assert(confirm.len == 0);
}