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