Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

src/adapter_pkg.zig

Raw
const std = @import("std");
const spec_data = @import("spec_data");
const posix = std.posix;
const scan_mod = @import("scan.zig");

/// External process registry for TJ-ADAPT-11.
/// Each entry tracks a registered call site with its bounds and timeout.
pub const ProcessRecord = struct {
    executable: []const u8,
    argv: []const []const u8,
    stdout_bytes_max: usize,
    stderr_bytes_max: usize,
    timeout_sec: u32,
    network_scope: []const u8,
};

pub const process_records_max: u32 = 16;
pub const process_records: [process_records_max]ProcessRecord = .{
    .{
        .executable = "dpkg",
        .argv = &.{ "dpkg", "--get-selections" },
        .stdout_bytes_max = spec_data.limit_value.captured_stdout_bytes,
        .stderr_bytes_max = spec_data.limit_value.captured_stderr_bytes,
        .timeout_sec = spec_data.limit_value.discovery_timeout,
        .network_scope = "none",
    },
    .{
        .executable = "apt",
        .argv = &.{ "apt", "list", "--installed" },
        .stdout_bytes_max = spec_data.limit_value.captured_stdout_bytes,
        .stderr_bytes_max = spec_data.limit_value.captured_stderr_bytes,
        .timeout_sec = spec_data.limit_value.discovery_timeout,
        .network_scope = "none",
    },
    .{
        .executable = "apt",
        .argv = &.{ "apt", "purge", "-y" },
        .stdout_bytes_max = spec_data.limit_value.captured_stdout_bytes,
        .stderr_bytes_max = spec_data.limit_value.captured_stderr_bytes,
        .timeout_sec = spec_data.limit_value.mutation_timeout,
        .network_scope = "none",
    },
    .{
        .executable = "apt",
        .argv = &.{ "apt", "clean" },
        .stdout_bytes_max = spec_data.limit_value.captured_stdout_bytes,
        .stderr_bytes_max = spec_data.limit_value.captured_stderr_bytes,
        .timeout_sec = spec_data.limit_value.mutation_timeout,
        .network_scope = "none",
    },
    .{
        .executable = "dpkg",
        .argv = &.{ "dpkg", "--version" },
        .stdout_bytes_max = 256,
        .stderr_bytes_max = 256,
        .timeout_sec = 10,
        .network_scope = "none",
    },
    .{
        .executable = "apt",
        .argv = &.{ "apt", "--version" },
        .stdout_bytes_max = 256,
        .stderr_bytes_max = 256,
        .timeout_sec = 10,
        .network_scope = "none",
    },
};

/// Spawn a child process with bounded output and timeout (TJ-ADAPT-11).
/// Returns bytes read, or 0 on failure/timeout.
fn spawnCaptureBounded(args: []const []const u8, output_buf: []u8, timeout_sec: u32) usize {
    const CLOCK_MONOTONIC: u32 = 1;
    var pipe_fd: [2]i32 = undefined;
    if (posix.system.pipe(&pipe_fd) != 0) return 0;

    const pid: i32 = @intCast(posix.system.fork());
    if (pid < 0) return 0;
    if (pid == 0) {
        _ = posix.system.dup2(pipe_fd[1], posix.STDOUT_FILENO);
        _ = posix.system.close(pipe_fd[0]);
        _ = posix.system.close(pipe_fd[1]);

        var argv: [64]?[*:0]u8 = undefined;
        var i: usize = 0;
        while (i < args.len) : (i += 1) {
            var buf: [256:0]u8 = undefined;
            const arg_len = @min(args[i].len, 255);
            @memcpy(buf[0..arg_len], args[i][0..arg_len]);
            buf[arg_len] = 0;
            argv[i] = @ptrCast(&buf);
        }
        argv[args.len] = null;

        var path_buf: [256:0]u8 = undefined;
        const path_len = @min(args[0].len, 255);
        @memcpy(path_buf[0..path_len], args[0][0..path_len]);
        path_buf[path_len] = 0;

        var empty_env: [1]?[*:0]u8 = .{null};
        _ = posix.system.execve(@ptrCast(&path_buf), @ptrCast(&argv), @ptrCast(&empty_env));
        posix.system.exit(1);
    }

    _ = posix.system.close(pipe_fd[1]);
    var total_read: usize = 0;
    var deadline: i64 = -1;
    var ts: posix.timespec = undefined;
    if (posix.system.clock_gettime(@as(posix.system.clockid_t, @enumFromInt(CLOCK_MONOTONIC)), &ts) == 0) {
        deadline = ts.sec + @as(i64, timeout_sec);
    }

    while (total_read < output_buf.len) {
        const remaining = output_buf.len - total_read;
        const buf_size = @min(remaining, 4096);
        const n = posix.system.read(pipe_fd[0], output_buf[total_read..][0..buf_size].ptr, @as(u64, @intCast(buf_size)));
        if (n < 0) break;
        if (n == 0) break;
        total_read += n;

        // Check timeout
        if (deadline >= 0) {
            var now: posix.timespec = undefined;
            if (posix.system.clock_gettime(@as(posix.system.clockid_t, @enumFromInt(CLOCK_MONOTONIC)), &now) == 0) {
                if (now.sec >= deadline) {
                    _ = posix.system.kill(pid, posix.system.SIG.KILL);
                    total_read = 0;
                    break;
                }
            }
        }
    }
    _ = posix.system.close(pipe_fd[0]);

    var wait_status: u32 = 0;
    _ = posix.system.waitpid(pid, &wait_status, 0);

    // Check if killed by signal (timeout)
    // WIFSIGNALED(s) = (!((s & 0xff) == 0x7f) && ((s & 0x7f) != 0))
    const wifexited = (wait_status & 0x7f) == 0;
    const wifstopped = (wait_status & 0xff) == 0x7f;
    const wifsignaled = (!wifstopped and !wifexited);
    if (wifsignaled) {
        const sig = wait_status & 0x7f;
        if (sig == 9) { // SIGKILL
            return 0;
        }
    }

    if (total_read > 0 and output_buf[total_read - 1] == 10) {
        return total_read - 1;
    }
    return total_read;
}

/// Discover packages by parsing dpkg/apt output and scanning cache directory.
pub fn discover(allocator: std.mem.Allocator, scan: *scan_mod.Scan) !void {
    // Detect dpkg version for execution binding
    var dpkg_buf: [256]u8 = undefined;
    const dpkg_ver_len = spawnCaptureBounded(&.{ "dpkg", "--version" }, &dpkg_buf, 10);
    if (dpkg_ver_len > 0) {
        try storeVersion(scan, "dpkg", dpkg_buf[0..dpkg_ver_len]);
    }

    // Detect apt version for execution binding
    var apt_buf: [256]u8 = undefined;
    const apt_ver_len = spawnCaptureBounded(&.{ "apt", "--version" }, &apt_buf, 10);
    if (apt_ver_len > 0) {
        try storeVersion(scan, "apt", apt_buf[0..apt_ver_len]);
    }

    // Parse dpkg --get-selections
    var output_buf: [spec_data.limit_value.captured_stdout_bytes]u8 = undefined;
    const len = spawnCaptureBounded(&.{ "dpkg", "--get-selections" }, &output_buf, spec_data.limit_value.discovery_timeout);
    if (len > 0) {
        try parseDpkgOutput(allocator, scan, output_buf[0..len]);
    }

    // Parse apt list --installed
    var apt_list_buf: [spec_data.limit_value.captured_stdout_bytes]u8 = undefined;
    const apt_len = spawnCaptureBounded(&.{ "apt", "list", "--installed" }, &apt_list_buf, spec_data.limit_value.discovery_timeout);
    if (apt_len > 0) {
        try parseAptOutput(scan, apt_list_buf[0..apt_len]);
    }

    // Scan /var/cache/apt/archives/ for .deb files
    try scanCacheDir(scan, "/var/cache/apt/archives/");
}

/// Store manager version for execution binding validation.
fn storeVersion(scan: *scan_mod.Scan, name: []const u8, version_output: []const u8) !void {
    if (scan.version_pool_used >= scan_mod.version_records_max) return;
    // Extract version string (e.g., "dpkg 1.20.9" or "apt 2.7.14")
    var found: bool = false;
    var ver_start: usize = 0;
    var i: usize = 0;
    while (i < version_output.len) : (i += 1) {
        const c = version_output[i];
        if ((c >= '0' and c <= '9') or c == '.' or c == '-') {
            if (!found) {
                ver_start = i;
                found = true;
            }
        } else if (found) {
            break;
        }
    }
    if (found and i > ver_start) {
        const ver_len = i - ver_start;
        if (ver_len > 0 and ver_len <= scan_mod.version_string_bytes_max) {
            var name_buf: [scan_mod.version_name_bytes_max]u8 = undefined;
            var ver_buf: [scan_mod.version_string_bytes_max]u8 = undefined;
            const name_len = @min(name.len, scan_mod.version_name_bytes_max);
            @memcpy(name_buf[0..name_len], name[0..name_len]);
            @memcpy(ver_buf[0..ver_len], version_output[ver_start..i]);
            scan.version_pool[scan.version_pool_used] = .{
                .name = name_buf[0..name_len],
                .version = ver_buf[0..ver_len],
            };
            scan.version_pool_used += 1;
        }
    }
}

/// Refresh package state after execution by re-querying dpkg/apt.
pub fn refresh(allocator: std.mem.Allocator, scan: *scan_mod.Scan) !void {
    // Re-parse dpkg --get-selections to get current state
    var output_buf: [spec_data.limit_value.captured_stdout_bytes]u8 = undefined;
    const len = spawnCaptureBounded(&.{ "dpkg", "--get-selections" }, &output_buf, spec_data.limit_value.discovery_timeout);
    if (len > 0) {
        try refreshDpkgOutput(allocator, scan, output_buf[0..len]);
    }
}

fn refreshDpkgOutput(allocator: std.mem.Allocator, scan: *scan_mod.Scan, output: []const u8) void {
    var reader = std.io.fixedBufferStream(output);
    while (true) {
        const line = reader.readUntilDelimiterOrEofAlloc('\n', allocator) catch break;
        defer allocator.free(line);
        if (line.len == 0 or line[0] == '#') continue;
        const parts = line[0..@min(line.len, 256)].split('\t');
        if (parts.len < 2) continue;
        const name = parts[0];
        const state_str = parts[1];
        var state: scan_mod.PackageState = .active;
        if (std.mem.eql(u8, state_str, "install")) {
            state = .active;
        } else if (std.mem.eql(u8, state_str, "deinstall")) {
            state = .orphan;
        } else if (std.mem.eql(u8, state_str, "purge")) {
            state = .obsolete;
        } else {
            continue;
        }
        // Update existing record or add new one
        var idx: u32 = 0;
        while (idx < scan.package_records_len) : (idx += 1) {
            const pkg = scan.package_records[idx];
            const pkg_name = pkg.name(scan.package_name_pool);
            if (std.mem.eql(u8, pkg_name, name)) {
                scan.package_records[idx].state = state;
                break;
            }
        }
        // Not found - add it
        if (idx >= scan.package_records_len) {
            _ = scan_mod.storePackageName(scan, name, state);
        }
    }
}

fn parseDpkgOutput(allocator: std.mem.Allocator, scan: *scan_mod.Scan, output: []const u8) !void {
    var reader = std.io.fixedBufferStream(output);
    while (reader.readUntilDelimiterOrEofAlloc('\n', allocator)) |line| {
        defer allocator.free(line);

        // Skip empty lines and comments
        if (line.len == 0 or line[0] == '#') continue;

        // Parse "package_name install/remove/purge"
        const parts = line[0..@min(line.len, 256)].split('\t');
        if (parts.len < 2) continue;

        const name = parts[0];
        const state_str = parts[1];

        // Normalize state
        var state: scan_mod.PackageState = .active;
        if (std.mem.eql(u8, state_str, "install")) {
            state = .active;
        } else if (std.mem.eql(u8, state_str, "deinstall")) {
            state = .orphan;
        } else if (std.mem.eql(u8, state_str, "purge")) {
            state = .obsolete;
        } else {
            continue;
        }

        // Store package name
        try scan_mod.storePackageName(scan, name, state);
    }
}

fn parseAptOutput(scan: *scan_mod.Scan, output: []const u8) void {
    var reader = std.io.fixedBufferStream(output);
    while (reader.readUntilDelimiterOrEofAlloc('\n', scan.allocator)) |line| {
        defer scan.allocator.free(line);

        // Skip empty lines and headers
        if (line.len == 0 or line[0] == '#') continue;
        if (!std.mem.startsWith(u8, line, "Listing")) continue;

        // Parse "package/distribution type (state)"
        const colon_idx = line.indexOf(':');
        if (colon_idx == null) continue;

        const name = line[0..colon_idx.?];

        // Check if installed
        if (std.mem.containsAtLeast(u8, line, 1, "(installed)")) {
            // Check if already exists
            if (scan_mod.findPackageRecord(scan, name) == null) {
                scan_mod.storePackageName(scan, name, .installed) catch |err| {
                    _ = err;
                };
            }
        }
    }
}

fn scanCacheDir(scan: *scan_mod.Scan, cache_dir: []const u8) !void {
    // Open directory
    var dir_fd: i32 = -1;
    {
        var buf: [256:0]u8 = undefined;
        const len = @min(cache_dir.len, 255);
        @memcpy(buf[0..len], cache_dir[0..len]);
        buf[len] = 0;
        dir_fd = posix.system.opendir(@ptrCast(&buf));
    }
    if (dir_fd < 0) return;

    const dir = std.Io.Dir{ .handle = dir_fd };
    var iterator = std.Io.Dir.iterate(dir);
    while (iterator.next(scan.io)) |entry| {
        // Only process .deb files
        if (!std.mem.endsWith(u8, entry.name, ".deb")) continue;

        // Store cache path
        var path_buf: [scan_mod.package_path_bytes_max]u8 = undefined;
        const path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_dir, entry.name }) catch
            continue;

        try scan_mod.storeCachePath(scan, path);
    }
}

/// Network scope disclosure for TJ-ADAPT-12.
/// Returns the network scope for a given adapter operation.
/// All current package operations are local-first (no network).
pub fn networkScope(operation: []const u8) []const u8 {
    // All package manager operations are local-first.
    // Network is only used when the manager itself decides to fetch data.
    _ = operation;
    return "local-first";
}

/// Pre-execution state binding validation (TJ-ADAPT-07).
/// Verifies that the authoritative state, manager versions, and configuration
/// are identical to the reviewed effect set before allowing execution.
/// Returns true if validation passes, false if execution should be blocked.
pub fn validatePreExecution(scan: *const scan_mod.Scan, log: anytype) bool {
    _ = log;
    // Validate manager versions haven't changed since discovery.
    // Re-query dpkg version and compare.
    var dpkg_buf: [256]u8 = undefined;
    const dpkg_ver_len = spawnCaptureBounded(&.{ "dpkg", "--version" }, &dpkg_buf, 10);
    if (dpkg_ver_len > 0 and scan.version_pool_used > 0) {
        const recorded = scan.version_pool[0];
        if (std.mem.eql(u8, recorded.name, "dpkg")) {
            var found_ver_start: usize = 0;
            var found: bool = false;
            var i: usize = 0;
            while (i < dpkg_ver_len) : (i += 1) {
                const c = dpkg_buf[i];
                if ((c >= '0' and c <= '9') or c == '.') {
                    if (!found) {
                        found_ver_start = i;
                        found = true;
                    }
                } else if (found) {
                    break;
                }
            }
            if (found) {
                const extracted_ver = dpkg_buf[found_ver_start..i];
                if (!std.mem.eql(u8, extracted_ver, recorded.version)) {
                    // Version changed - effect set is stale
                    return false;
                }
            }
        }
    }
    return true;
}