repositories / termux-janitor
termux-janitor
Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.
owned by admin
src/scan.zig
Raw//! Bounded scan generation owned by `spec/PRODUCT.md` sections 4, 7, and 8
//! and `spec/FILESYSTEM_CAPABILITIES.md`.
//!
//! Traversal uses `lstat` semantics without following symlinks, crosses
//! mount boundaries, keeps one attempt outcome per enumerated entry, and
//! never recurses: the directory stack is a fixed array bounded by the
//! registered traversal depth. Every capacity is a registered limit;
//! exhaustion marks the affected root incomplete and increments the matching
//! omission counter instead of dropping data silently.
const std = @import("std");
const linux = std.os.linux;
const spec_data = @import("spec_data");
const config_mod = @import("config.zig");
pub const raw_path_bytes_max: u32 = @intCast(spec_data.limit_value.raw_path_bytes);
pub const raw_component_bytes_max: u32 = @intCast(spec_data.limit_value.raw_component_bytes);
pub const symlink_target_bytes_max: u32 = @intCast(spec_data.limit_value.symlink_target_bytes);
pub const traversal_depth_max: u32 = @intCast(spec_data.limit_value.traversal_depth);
pub const retained_findings_max: u32 = @intCast(spec_data.limit_value.retained_findings);
pub const retained_warnings_max: u32 = @intCast(spec_data.limit_value.retained_warnings);
pub const identity_domains_max: u32 = @intCast(spec_data.limit_value.filesystem_identity_domains);
/// Per-finding name bytes: one component per finding, so the retained-name
/// pool is the registered per-component bound times the retained count.
pub const name_pool_bytes: u64 = @as(u64, retained_findings_max) * raw_component_bytes_max;
/// Per-finding symlink target bytes for symlink findings only.
pub const target_pool_bytes: u64 = @as(u64, retained_findings_max) * symlink_target_bytes_max;
pub const no_parent: u32 = std.math.maxInt(u32);
pub const unknown_size: u64 = std.math.maxInt(u64);
pub const Kind = enum { file, directory, symlink, other };
pub const TargetStatus = enum { none, resolved, broken, loop, depth_exhausted, untrusted, unknown };
pub const AttemptOutcome = enum {
retained,
excluded,
deduplicated,
disappeared,
metadata_failed,
crossed_mount,
capacity_exhausted,
depth_exhausted,
cancelled,
};
pub const Warning = struct {
/// Bounded human-readable text; never operator path bytes.
text: []const u8,
finding: u32 = no_parent,
};
/// One retained filesystem object. Raw authority bytes are reconstructable
/// from the parent chain plus `name`; no display text is stored here.
pub const Finding = struct {
parent: u32 = no_parent,
name: []const u8 = &.{},
depth: u32 = 0,
kind: Kind = .file,
dev_major: u32 = 0,
dev_minor: u32 = 0,
ino: u64 = 0,
mnt_id: u64 = 0,
nlink: u64 = 0,
apparent_size: u64 = 0,
blocks_known: bool = false,
allocated_bytes: u64 = 0,
mtime_sec: i64 = 0,
mtime_nsec: u32 = 0,
mtime_known: bool = false,
/// Symlink target bytes inside the target pool, empty otherwise.
target: []const u8 = &.{},
target_status: TargetStatus = .none,
target_dev_known: bool = false,
target_ino: u64 = 0,
/// Recursive apparent-size aggregate for directories; `unknown_size`
/// when any descendant contribution is unknown.
aggregate_apparent: u64 = 0,
aggregate_known: bool = false,
/// Set when the finding shares a trusted inode with another displayed
/// row in the same aggregate scope (`TJ-SIZE-05`).
non_additive: bool = false,
root_index: u32 = 0,
category: u16 = 0,
manual_only: bool = true,
selected: bool = false,
};
pub const Domain = struct {
dev_major: u32,
dev_minor: u32,
mnt_id: u64,
};
pub const Ledger = struct {
retained: u64 = 0,
excluded: u64 = 0,
deduplicated: u64 = 0,
disappeared: u64 = 0,
metadata_failed: u64 = 0,
crossed_mount: u64 = 0,
capacity_exhausted: u64 = 0,
depth_exhausted: u64 = 0,
};
/// One scan generation. All storage is fixed at initialization.
pub const Scan = struct {
findings: [retained_findings_max]Finding = undefined,
findings_len: u32 = 0,
warnings: [retained_warnings_max]Warning = undefined,
warnings_len: u32 = 0,
domains: [identity_domains_max]Domain = undefined,
domains_len: u32 = 0,
name_pool: [name_pool_bytes]u8 = undefined,
name_pool_used: u64 = 0,
target_pool: [target_pool_bytes]u8 = undefined,
target_pool_used: u64 = 0,
ledger: Ledger = .{},
/// Realtime reference captured once before the first entry is examined.
realtime_sec: i64 = 0,
complete: bool = true,
cancelled: bool = false,
/// Finding index of each scanned root, ordered by root index; execution
/// revalidation compares the reopened root against this identity
/// (`TJ-EXEC-01`).
root_findings: [config_mod.roots_max]u32 = undefined,
root_findings_len: u32 = 0,
pub fn finding(self: *const Scan, index: u32) *const Finding {
std.debug.assert(index < self.findings_len);
return &self.findings[index];
}
pub fn warn(self: *Scan, text: []const u8, finding_index: u32) void {
if (self.warnings_len >= retained_warnings_max) {
self.complete = false;
return;
}
self.warnings[self.warnings_len] = .{ .text = text, .finding = finding_index };
self.warnings_len += 1;
}
pub fn storeName(self: *Scan, name: []const u8) ?[]const u8 {
if (name.len == 0 or name.len > raw_component_bytes_max) return null;
if (self.name_pool_used + name.len > self.name_pool.len) return null;
const start: usize = @intCast(self.name_pool_used);
@memcpy(self.name_pool[start..][0..name.len], name);
self.name_pool_used += name.len;
return self.name_pool[start..][0..name.len];
}
pub fn storeTarget(self: *Scan, target: []const u8) ?[]const u8 {
if (target.len > symlink_target_bytes_max) return null;
if (self.target_pool_used + target.len > self.target_pool.len) return null;
const start: usize = @intCast(self.target_pool_used);
@memcpy(self.target_pool[start..][0..target.len], target);
self.target_pool_used += target.len;
return self.target_pool[start..][0..target.len];
}
fn domainIndex(self: *Scan, stat: *const linux.Statx) ?u32 {
var index: u32 = 0;
while (index < self.domains_len) : (index += 1) {
const domain = self.domains[index];
if (domain.dev_major == stat.dev_major and
domain.dev_minor == stat.dev_minor and
domain.mnt_id == stat.mnt_id) return index;
}
if (self.domains_len >= identity_domains_max) return null;
self.domains[self.domains_len] = .{
.dev_major = stat.dev_major,
.dev_minor = stat.dev_minor,
.mnt_id = stat.mnt_id,
};
self.domains_len += 1;
return self.domains_len - 1;
}
/// Reconstructs the raw absolute path of `index` into `out` by walking
/// the parent chain. Bounded by the registered path length.
pub fn pathOf(self: *const Scan, index: u32, out: *[raw_path_bytes_max]u8) []const u8 {
var chain: [traversal_depth_max + 2]u32 = undefined;
var depth: usize = 0;
var current = index;
while (current != no_parent and depth < chain.len) {
chain[depth] = current;
depth += 1;
current = self.findings[current].parent;
}
std.debug.assert(depth > 0 and depth <= chain.len);
var written: usize = 0;
var level = depth;
while (level > 0) {
level -= 1;
const node = self.findings[chain[level]];
const name = node.name;
if (written == 0) {
// The root's stored name is its absolute path.
std.debug.assert(written + name.len <= out.len);
@memcpy(out[written..][0..name.len], name);
written += name.len;
continue;
}
std.debug.assert(written + 1 + name.len <= out.len);
out[written] = '/';
written += 1;
@memcpy(out[written..][0..name.len], name);
written += name.len;
}
return out[0..written];
}
};
const DirLevel = struct {
dir: std.Io.Dir,
finding_index: u32,
iterator: std.Io.Dir.Iterator,
};
pub var name_nul: [raw_component_bytes_max + 1:0]u8 = undefined;
/// Reads one statx record for `name` inside `dir_fd` without following
/// symlinks. `name` must include a NUL terminator in its buffer.
/// Stats an open descriptor itself (empty path requires `AT_EMPTY_PATH`).
pub fn statxFd(fd: std.posix.fd_t) !linux.Statx {
var stat: linux.Statx = std.mem.zeroes(linux.Statx);
const mask: linux.STATX = .{
.TYPE = true,
.NLINK = true,
.INO = true,
.SIZE = true,
.BLOCKS = true,
.MTIME = true,
.MNT_ID = true,
};
const rc = linux.statx(fd, "", linux.AT.EMPTY_PATH, mask, &stat);
if (linux.errno(rc) != .SUCCESS) return error.StatFailed;
if (!stat.mask.TYPE or !stat.mask.INO or !stat.mask.MNT_ID) return error.StatIncomplete;
return stat;
}
pub fn statxAt(dir_fd: std.posix.fd_t, name: [*:0]const u8) !linux.Statx {
var stat: linux.Statx = std.mem.zeroes(linux.Statx);
const mask: linux.STATX = .{
.TYPE = true,
.NLINK = true,
.INO = true,
.SIZE = true,
.BLOCKS = true,
.MTIME = true,
.MNT_ID = true,
};
const rc = linux.statx(dir_fd, name, linux.AT.SYMLINK_NOFOLLOW, mask, &stat);
if (linux.errno(rc) != .SUCCESS) return error.StatFailed;
if (!stat.mask.TYPE or !stat.mask.INO or !stat.mask.MNT_ID) return error.StatIncomplete;
return stat;
}
pub fn kindOf(stat: *const linux.Statx) ?Kind {
const kind_bits = stat.mode & linux.S.IFMT;
return switch (kind_bits) {
linux.S.IFREG => .file,
linux.S.IFDIR => .directory,
linux.S.IFLNK => .symlink,
else => .other,
};
}
pub const Root = struct {
/// Absolute raw path of the configured root (already normalized).
path: []const u8,
finding: u32 = no_parent,
open_failed: bool = false,
};
/// Opens one configured root directory and records its finding.
fn openRoot(scan: *Scan, root_path: []const u8) ?std.Io.Dir {
const fd = std.posix.openat(
std.posix.AT.FDCWD,
root_path,
.{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
0,
) catch {
scan.complete = false;
scan.warn("warning: scan root is inaccessible", no_parent);
return null;
};
return .{ .handle = fd };
}
/// Runs one scan generation over the configured roots. Single pass, no
/// recursion, bounded pools. Every enumeration checkpoint observes the
/// cancellation flag and stops admission promptly when set.
pub fn run(scan: *Scan, config: *const config_mod.Config, roots: []const Root) void {
const reference = std.Io.Timestamp.now(std.Io.Threaded.global_single_threaded.io(), .real);
scan.realtime_sec = @intCast(@divTrunc(reference.nanoseconds, std.time.ns_per_s));
var stack: [traversal_depth_max]DirLevel = undefined;
var root_index: u32 = 0;
while (root_index < roots.len) : (root_index += 1) {
if (scan.cancelled) {
scan.complete = false;
return;
}
const root_path = roots[root_index].path;
var dir = openRoot(scan, root_path) orelse continue;
defer dir.close(std.Io.Threaded.global_single_threaded.io());
const stat = statxFd(dir.handle) catch {
scan.complete = false;
scan.warn("warning: scan root metadata is unavailable", no_parent);
continue;
};
if (scan.findings_len >= retained_findings_max) {
scan.ledger.capacity_exhausted += 1;
scan.complete = false;
continue;
}
const stored_name = scan.storeName(root_path) orelse {
scan.ledger.capacity_exhausted += 1;
scan.complete = false;
continue;
};
const index = scan.findings_len;
scan.findings[index] = .{
.parent = no_parent,
.name = stored_name,
.depth = 0,
.kind = .directory,
.dev_major = stat.dev_major,
.dev_minor = stat.dev_minor,
.ino = stat.ino,
.mnt_id = stat.mnt_id,
.nlink = stat.nlink,
.apparent_size = if (stat.mask.SIZE) stat.size else unknown_size,
.blocks_known = stat.mask.BLOCKS,
.allocated_bytes = if (stat.mask.BLOCKS)
std.math.mul(u64, stat.blocks, 512) catch unknown_size
else
unknown_size,
.mtime_sec = if (stat.mask.MTIME) stat.mtime.sec else 0,
.mtime_nsec = if (stat.mask.MTIME) stat.mtime.nsec else 0,
.mtime_known = stat.mask.MTIME,
.root_index = root_index,
};
scan.findings_len += 1;
scan.root_findings[scan.root_findings_len] = index;
scan.root_findings_len += 1;
scan.ledger.retained += 1;
roots_scan: {
const io = std.Io.Threaded.global_single_threaded.io();
var level: usize = 1;
stack[0] = .{
.dir = dir,
.finding_index = index,
.iterator = std.Io.Dir.iterate(dir),
};
while (level > 0) {
if (scan.cancelled) {
scan.complete = false;
scan.complete = false;
break :roots_scan;
}
const top = &stack[level - 1];
const entry = top.iterator.next(io) catch {
scan.complete = false;
scan.warn("warning: directory enumeration failed", top.finding_index);
level -= 1;
continue;
};
const found = entry orelse {
level -= 1;
continue;
};
const admitted = admit(
scan,
config,
top.dir.handle,
top.finding_index,
level,
found.name,
&stack,
);
switch (admitted) {
.stop => break :roots_scan,
.keep => {},
.descend => level += 1,
}
}
}
}
}
/// One admission outcome: `stop` aborts the scan, `keep` continues the
/// current level, and `descend` announces a child level pushed onto the
/// traversal stack so the caller advances (`TJ-SCAN-01`).
const Admit = enum { stop, keep, descend };
fn admit(
scan: *Scan,
config: *const config_mod.Config,
parent_dir_fd: std.posix.fd_t,
parent_finding: u32,
parent_level: usize,
name: []const u8,
stack: *[traversal_depth_max]DirLevel,
) Admit {
if (name.len > raw_component_bytes_max) {
scan.ledger.capacity_exhausted += 1;
scan.complete = false;
return .keep;
}
@memcpy(name_nul[0..name.len], name);
name_nul[name.len] = 0;
var stat = statxAt(parent_dir_fd, &name_nul) catch {
scan.ledger.metadata_failed += 1;
scan.complete = false;
return .keep;
};
const kind = kindOf(&stat) orelse .other;
// Exclusion boundary check uses the reconstructed raw path.
var path_buffer: [raw_path_bytes_max]u8 = undefined;
const parent_path = scan.pathOf(parent_finding, &path_buffer);
var joined: [raw_path_bytes_max]u8 = undefined;
const path = std.fmt.bufPrint(&joined, "{s}/{s}", .{ parent_path, name }) catch {
scan.ledger.capacity_exhausted += 1;
scan.complete = false;
return .keep;
};
if (config.excluded(path)) {
scan.ledger.excluded += 1;
return .keep;
}
if (scan.findings_len >= retained_findings_max) {
scan.ledger.capacity_exhausted += 1;
scan.complete = false;
return .stop;
}
const stored_name = scan.storeName(name) orelse {
scan.ledger.capacity_exhausted += 1;
scan.complete = false;
return .stop;
};
const index = scan.findings_len;
scan.findings[index] = .{
.parent = parent_finding,
.name = stored_name,
.depth = @intCast(parent_level),
.kind = kind,
.dev_major = stat.dev_major,
.dev_minor = stat.dev_minor,
.ino = stat.ino,
.mnt_id = stat.mnt_id,
.nlink = stat.nlink,
.apparent_size = if (stat.mask.SIZE) stat.size else unknown_size,
.blocks_known = stat.mask.BLOCKS,
.allocated_bytes = if (stat.mask.BLOCKS)
std.math.mul(u64, stat.blocks, 512) catch unknown_size
else
unknown_size,
.mtime_sec = if (stat.mask.MTIME) stat.mtime.sec else 0,
.mtime_nsec = if (stat.mask.MTIME) stat.mtime.nsec else 0,
.mtime_known = stat.mask.MTIME,
.root_index = scan.findings[parent_finding].root_index,
};
scan.findings_len += 1;
scan.ledger.retained += 1;
if (kind == .symlink) tryRecordTarget(scan, parent_dir_fd, &stat, index);
if (kind == .directory) {
if (parent_level >= traversal_depth_max) {
scan.ledger.depth_exhausted += 1;
scan.complete = false;
scan.warn("warning: traversal depth bound reached", index);
return .keep;
}
const child_fd = std.posix.openat(
parent_dir_fd,
name_nul[0..name.len :0],
.{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true, .NOFOLLOW = true },
0,
) catch {
scan.ledger.metadata_failed += 1;
scan.complete = false;
return .keep;
};
const child_dir: std.Io.Dir = .{ .handle = child_fd };
stack[parent_level] = .{
.dir = child_dir,
.finding_index = index,
.iterator = std.Io.Dir.iterate(child_dir),
};
return .descend;
}
return .keep;
}
fn tryRecordTarget(scan: *Scan, parent_dir_fd: std.posix.fd_t, stat: *const linux.Statx, index: u32) void {
var target_buffer: [symlink_target_bytes_max + 1]u8 = undefined;
const length = linux.readlinkat(
parent_dir_fd,
&name_nul,
&target_buffer,
symlink_target_bytes_max + 1,
);
if (linux.errno(length) != .SUCCESS) {
scan.findings[index].target_status = .unknown;
return;
}
const bounded: usize = @intCast(length);
if (bounded > symlink_target_bytes_max) {
scan.findings[index].target_status = .unknown;
return;
}
const stored = scan.storeTarget(target_buffer[0..bounded]) orelse {
scan.findings[index].target_status = .unknown;
return;
};
scan.findings[index].target = stored;
resolveTarget(scan, stat, index, target_buffer[0..bounded], 0);
}
fn resolveTarget(
scan: *Scan,
origin: *const linux.Statx,
index: u32,
target: []const u8,
hops: u32,
) void {
if (hops > symlink_resolution_hops_max) {
scan.findings[index].target_status = .loop;
return;
}
// Version 1 resolves targets in the host namespace only; proot
// namespaces require their adapter and stay `untrusted`.
if (hops > 0 or (target.len > 0 and target[0] == '/')) {
const fd = std.posix.openat(
std.posix.AT.FDCWD,
target,
.{ .ACCMODE = .RDONLY, .CLOEXEC = true },
0,
) catch {
scan.findings[index].target_status = .broken;
return;
};
defer _ = std.os.linux.close(fd);
const stat = statxAt(fd, "") catch {
scan.findings[index].target_status = .unknown;
return;
};
if (stat.dev_major != origin.dev_major or stat.dev_minor != origin.dev_minor) {
scan.findings[index].target_status = .untrusted;
return;
}
scan.findings[index].target_status = .resolved;
scan.findings[index].target_ino = stat.ino;
scan.findings[index].target_dev_known = true;
return;
}
// Relative host-namespace resolution walks from the symlink parent.
scan.findings[index].target_status = .unknown;
}
pub const symlink_resolution_hops_max: u32 = 40;