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