repositories / termux-janitor
termux-janitor
Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.
owned by admin
src/adapter_pkg.zig
Rawconst 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;
}