Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

src/config.zig

Raw
//! Configuration loading owned by `spec/CONFIGURATION.md`.
//! One strict UTF-8 JSON object no larger than the documented 1 MiB bound,
//! registered capacities for every array, compiled defaults from the
//! generated registries. Invalid safety-relevant configuration never starts
//! a scan. All string bytes are copied into one fixed initialization buffer
//! allocated once; slices into it never dangle and never grow.
const std = @import("std");
const spec_data = @import("spec_data");

/// The configuration size bound documented in `spec/CONFIGURATION.md`.
pub const config_bytes_max: u32 = 1024 * 1024;
pub const roots_max: u32 = @intCast(spec_data.limit_value.configured_scan_roots);
pub const workspace_roots_max: u32 = @intCast(spec_data.limit_value.workspace_roots);
pub const document_roots_max: u32 = @intCast(spec_data.limit_value.document_roots);
pub const exclusions_max: u32 = @intCast(spec_data.limit_value.exclusions);
pub const manual_only_rules_max: u32 = @intCast(spec_data.limit_value.manual_only_rules);
pub const path_bytes_max: usize = @intCast(spec_data.limit_value.raw_path_bytes);
pub const components_max: u32 = @intCast(spec_data.limit_value.traversal_depth);

/// Worst case: every array slot holds one maximal path plus headroom for
/// rule words and the resolved source path. Initialization fails closed when
/// the parsed file cannot fit; the file itself is already capped at
/// `config_bytes_max`.
pub const buffer_bytes_max: u32 = 2 * 1024 * 1024;

pub const Thresholds = struct {
    large_file_bytes: u64 = spec_data.threshold_value.large_file_bytes,
    large_directory_bytes: u64 = spec_data.threshold_value.large_directory_bytes,
    old_seconds: u64 = spec_data.threshold_value.old_seconds,
    stale_temporary_seconds: u64 = spec_data.threshold_value.stale_temporary_seconds,
    stale_trash_seconds: u64 = spec_data.threshold_value.stale_trash_seconds,
    old_rotated_log_seconds: u64 = spec_data.threshold_value.old_rotated_log_seconds,
};

pub const ManualOnlyRule = struct {
    /// Exactly one of `root`, `glob`, or `suffix` is nonempty.
    root: []const u8 = "",
    glob: []const u8 = "",
    suffix: []const u8 = "",
    /// Optional, nonempty when present.
    reason: []const u8 = "",
};

pub const Error = error{
    ConfigUnreadable,
    ConfigNotRegular,
    ConfigOversized,
    ConfigSyntax,
    ConfigSchema,
    ConfigUnknownKey,
    ConfigType,
    ConfigRange,
    ConfigCapacity,
    ConfigPathBase,
    OutOfMemory,
};

pub const PathList = struct {
    paths: [roots_max][]const u8 = undefined,
    len: u32 = 0,

    pub fn contains(self: *const PathList, path: []const u8) bool {
        var index: u32 = 0;
        while (index < self.len) : (index += 1) {
            if (std.mem.eql(u8, self.paths[index], path)) return true;
        }
        return false;
    }
};

pub const Config = struct {
    scan_roots: PathList = .{},
    workspace_roots: PathList = .{},
    document_roots: PathList = .{},
    exclusions: PathList = .{},
    manual_only_rules: [manual_only_rules_max]ManualOnlyRule = undefined,
    manual_only_rules_len: u32 = 0,
    thresholds: Thresholds = .{},
    offline: bool = false,
    color: bool = true,
    ascii: bool = false,
    /// Empty when compiled defaults apply with no configuration file.
    source_path: []const u8 = "",

    /// A prefix matches only on a complete path-component boundary.
    pub fn excluded(self: *const Config, path: []const u8) bool {
        var index: u32 = 0;
        while (index < self.exclusions.len) : (index += 1) {
            if (prefixMatches(self.exclusions.paths[index], path)) return true;
        }
        return false;
    }
};

pub fn prefixMatches(prefix: []const u8, path: []const u8) bool {
    if (prefix.len == 0) return false;
    if (path.len < prefix.len) return false;
    if (!std.mem.startsWith(u8, path, prefix)) return false;
    if (path.len == prefix.len) return true;
    if (prefix[prefix.len - 1] == '/') return true;
    return path[prefix.len] == '/';
}

/// One initialization-time byte arena for every configuration string.
pub const Buffer = struct {
    bytes: [buffer_bytes_max]u8 = undefined,
    used: u32 = 0,

    pub fn store(self: *Buffer, value: []const u8) Error![]const u8 {
        if (value.len > path_bytes_max) return Error.ConfigCapacity;
        if (value.len > self.bytes.len - self.used) return Error.ConfigCapacity;
        const start = self.used;
        @memcpy(self.bytes[start..][0..value.len], value);
        self.used += @intCast(value.len);
        return self.bytes[start..][0..value.len];
    }
};

pub const Environment = struct {
    home: []const u8 = "",
    prefix: []const u8 = "",
    xdg_config_home: []const u8 = "",
    xdg_state_home: []const u8 = "",
    xdg_data_home: []const u8 = "",
    tmpdir: []const u8 = "",
    no_color: []const u8 = "",

    pub fn capture(init: *std.process.Init.Minimal) Environment {
        const environ = init.environ;
        return .{
            .home = environ.getPosix("HOME") orelse "",
            .prefix = environ.getPosix("PREFIX") orelse "",
            .xdg_config_home = environ.getPosix("XDG_CONFIG_HOME") orelse "",
            .xdg_state_home = environ.getPosix("XDG_STATE_HOME") orelse "",
            .xdg_data_home = environ.getPosix("XDG_DATA_HOME") orelse "",
            .tmpdir = environ.getPosix("TMPDIR") orelse "",
            .no_color = environ.getPosix("NO_COLOR") orelse "",
        };
    }

    fn baseIsUsable(base: []const u8) bool {
        return base.len > 0 and base[0] == '/';
    }

    /// `<base>/termux-janitor/config.json` for XDG bases; error when the
    /// fallback cannot be established, which is a startup error where a
    /// configuration file is actually required.
    pub fn configBasePath(self: *const Environment, out: *[path_bytes_max]u8) Error![]const u8 {
        var fallback: [path_bytes_max]u8 = undefined;
        const base: []const u8 = if (baseIsUsable(self.xdg_config_home))
            self.xdg_config_home
        else if (baseIsUsable(self.home)) join: {
            const joined = std.fmt.bufPrint(&fallback, "{s}/.config", .{self.home}) catch
                return Error.ConfigPathBase;
            break :join joined;
        } else return Error.ConfigPathBase;
        return std.fmt.bufPrint(out, "{s}/termux-janitor/config.json", .{base}) catch
            Error.ConfigPathBase;
    }

    pub fn stateBasePath(self: *const Environment, out: *[path_bytes_max]u8) Error![]const u8 {
        var fallback: [path_bytes_max]u8 = undefined;
        const base: []const u8 = if (baseIsUsable(self.xdg_state_home))
            self.xdg_state_home
        else if (baseIsUsable(self.home)) join: {
            const joined = std.fmt.bufPrint(&fallback, "{s}/.local/state", .{self.home}) catch
                return Error.ConfigPathBase;
            break :join joined;
        } else return Error.ConfigPathBase;
        return std.fmt.bufPrint(out, "{s}/termux-janitor", .{base}) catch
            Error.ConfigPathBase;
    }
};

pub const Normalized = struct {
    bytes: [path_bytes_max]u8 = undefined,
    len: u32 = 0,

    pub fn slice(self: *const Normalized) []const u8 {
        return self.bytes[0..self.len];
    }
};

/// Lexically normalizes a configuration-relative or absolute path: removes
/// `.` components, resolves `..` without crossing the filesystem root, and
/// never follows symlinks. Relative paths resolve against `base`.
pub fn normalizePath(base: []const u8, path: []const u8, out: *Normalized) Error!void {
    var components: [components_max][]const u8 = undefined;
    var depth: usize = 0;
    if (path.len > 0 and path[0] == '/') {
        components[0] = "";
        depth = 1;
    } else if (base.len > 0 and base[0] == '/') {
        var rest = base;
        components[0] = "";
        depth = 1;
        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 or std.mem.eql(u8, component, ".")) continue;
            pushComponent(&components, &depth, component) catch return Error.ConfigCapacity;
        }
    } else {
        return Error.ConfigPathBase;
    }
    var rest = if (path.len > 0 and path[0] == '/') path[1..] else 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 or std.mem.eql(u8, component, ".")) continue;
        if (std.mem.eql(u8, component, "..")) {
            if (depth == 1 and components[0].len == 0) continue; // never cross root
            if (depth == 0) return Error.ConfigPathBase;
            depth -= 1;
            continue;
        }
        pushComponent(&components, &depth, component) catch return Error.ConfigCapacity;
    }
    if (depth == 0) return Error.ConfigPathBase;
    var written: usize = 0;
    var index: usize = if (components[0].len == 0) 1 else 0;
    if (components[0].len == 0) {
        out.bytes[0] = '/';
        written = 1;
    }
    while (index < depth) : (index += 1) {
        const component = components[index];
        if (component.len == 0) continue;
        if (written > 1 or (written == 1 and out.bytes[0] != '/')) {
            if (written + 1 > out.bytes.len) return Error.ConfigCapacity;
            out.bytes[written] = '/';
            written += 1;
        } else if (written == 0) {
            // relative base with first component: nothing to prepend
        } else if (written == 1 and out.bytes[0] == '/') {
            // root separator already present
        }
        if (written + component.len > out.bytes.len) return Error.ConfigCapacity;
        @memcpy(out.bytes[written..][0..component.len], component);
        written += component.len;
    }
    if (written == 0) return Error.ConfigPathBase;
    out.len = @intCast(written);
}

fn pushComponent(components: *[components_max][]const u8, depth: *usize, component: []const u8) error{Overflow}!void {
    if (depth.* >= components.len) return error.Overflow;
    components[depth.*] = component;
    depth.* += 1;
}

const top_level_keys = [_][]const u8{
    "schema_version",
    "scan_roots",
    "workspace_roots",
    "document_roots",
    "exclusions",
    "manual_only_rules",
    "thresholds",
    "adapters",
    "network",
    "display",
};
const threshold_keys = [_][]const u8{
    "large_file_bytes",
    "large_directory_bytes",
    "old_seconds",
    "stale_temporary_seconds",
    "stale_trash_seconds",
    "old_rotated_log_seconds",
};

pub const Loaded = struct {
    config: Config = .{},
    file_existed: bool = false,
    /// Directory of the selected configuration file for relative resolution.
    base_dir: []const u8 = "",
};

/// Loads compiled defaults, then the explicit file or the discovered default
/// file. A missing explicit file is `Error.ConfigUnreadable`; a missing
/// default file means compiled defaults.
pub fn load(
    io: std.Io,
    environment: *const Environment,
    explicit_path: ?[]const u8,
    buffer: *Buffer,
    path_out: *Normalized,
) Error!Loaded {
    var result: Loaded = .{};
    var discovered: Normalized = .{};
    const path: []const u8 = if (explicit_path) |given| explicit: {
        try normalizePath("", given, &discovered);
        break :explicit discovered.slice();
    } else environment.configBasePath(&discovered.bytes) catch |err| return err;
    var path_nul: [path_bytes_max + 1:0]u8 = undefined;
    if (path.len > path_bytes_max) return Error.ConfigPathBase;
    @memcpy(path_nul[0..path.len], path);
    path_nul[path.len] = 0;
    var statx_buffer: std.os.linux.Statx = std.mem.zeroes(std.os.linux.Statx);
    const rc = std.os.linux.statx(
        std.posix.AT.FDCWD,
        &path_nul,
        std.os.linux.AT.SYMLINK_NOFOLLOW,
        .{ .TYPE = true },
        &statx_buffer,
    );
    if (rc != 0) {
        if (explicit_path != null) return Error.ConfigUnreadable;
        return result;
    }
    if ((statx_buffer.mode & std.os.linux.S.IFMT) != std.os.linux.S.IFREG) {
        if (explicit_path != null) return Error.ConfigNotRegular;
        return result;
    }
    if (statx_buffer.size > config_bytes_max) return Error.ConfigOversized;
    const bytes = std.Io.Dir.cwd().readFileAlloc(
        io,
        path,
        std.heap.page_allocator,
        .limited(config_bytes_max),
    ) catch return Error.ConfigUnreadable;
    defer std.heap.page_allocator.free(bytes);
    const stored_path = try buffer.store(path);
    result.config.source_path = stored_path;
    try setBaseDir(stored_path, &result.base_dir, buffer);
    try parse(bytes, buffer, &result.config, result.base_dir);
    result.file_existed = true;
    @memcpy(path_out.bytes[0..stored_path.len], stored_path);
    path_out.len = @intCast(stored_path.len);
    return result;
}

fn setBaseDir(path: []const u8, base_dir: *[]const u8, buffer: *Buffer) Error!void {
    const slash = std.mem.lastIndexOfScalar(u8, path, '/');
    const base = if (slash) |index| path[0..index] else "/";
    base_dir.* = try buffer.store(base);
}

fn parse(bytes: []const u8, buffer: *Buffer, config: *Config, base_dir: []const u8) Error!void {
    var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, bytes, .{
        .duplicate_field_behavior = .use_first,
        .max_value_len = config_bytes_max,
    }) catch |err| switch (err) {
        error.OutOfMemory => return Error.OutOfMemory,
        else => return Error.ConfigSyntax,
    };
    defer parsed.deinit();
    const root = parsed.value;
    if (root != .object) return Error.ConfigSchema;
    var seen_top = [1]bool{false} ** top_level_keys.len;
    var seen_schema = false;
    var iterator = root.object.iterator();
    while (iterator.next()) |entry| {
        const key = entry.key_ptr.*;
        const known = keyIndex(&top_level_keys, key) orelse return Error.ConfigUnknownKey;
        if (seen_top[known]) return Error.ConfigSyntax;
        seen_top[known] = true;
        const value = entry.value_ptr.*;
        if (std.mem.eql(u8, key, "schema_version")) {
            seen_schema = true;
            if (value != .integer or value.integer != 1) return Error.ConfigSchema;
        } else if (std.mem.eql(u8, key, "scan_roots")) {
            try parsePathList(value, buffer, base_dir, &config.scan_roots);
        } else if (std.mem.eql(u8, key, "workspace_roots")) {
            try parsePathList(value, buffer, base_dir, &config.workspace_roots);
        } else if (std.mem.eql(u8, key, "document_roots")) {
            try parsePathList(value, buffer, base_dir, &config.document_roots);
        } else if (std.mem.eql(u8, key, "exclusions")) {
            try parsePathList(value, buffer, base_dir, &config.exclusions);
        } else if (std.mem.eql(u8, key, "manual_only_rules")) {
            try parseRules(value, buffer, config);
        } else if (std.mem.eql(u8, key, "thresholds")) {
            try parseThresholds(value, config);
        } else if (std.mem.eql(u8, key, "adapters")) {
            try parseAdapters(value);
        } else if (std.mem.eql(u8, key, "network")) {
            try parseNetwork(value, config);
        } else if (std.mem.eql(u8, key, "display")) {
            try parseDisplay(value, config);
        }
    }
    if (!seen_schema) return Error.ConfigSchema;
    if (config.thresholds.large_directory_bytes < config.thresholds.large_file_bytes) {
        return Error.ConfigRange;
    }
}

fn keyIndex(keys: []const []const u8, key: []const u8) ?usize {
    for (keys, 0..) |candidate, index| {
        if (std.mem.eql(u8, candidate, key)) return index;
    }
    return null;
}

fn parsePathList(value: std.json.Value, buffer: *Buffer, base_dir: []const u8, list: *PathList) Error!void {
    if (value != .array) return Error.ConfigType;
    if (value.array.items.len > list.paths.len) return Error.ConfigCapacity;
    var normalized: Normalized = .{};
    for (value.array.items) |item| {
        if (item != .string) return Error.ConfigType;
        if (item.string.len == 0) return Error.ConfigRange;
        try normalizePath(base_dir, item.string, &normalized);
        if (list.len >= list.paths.len) return Error.ConfigCapacity;
        list.paths[list.len] = try buffer.store(normalized.slice());
        list.len += 1;
    }
}

fn parseRules(value: std.json.Value, buffer: *Buffer, config: *Config) Error!void {
    if (value != .array) return Error.ConfigType;
    if (value.array.items.len > manual_only_rules_max) return Error.ConfigCapacity;
    for (value.array.items) |item| {
        if (item != .object) return Error.ConfigType;
        var rule: ManualOnlyRule = .{};
        var selections: u32 = 0;
        var iterator = item.object.iterator();
        while (iterator.next()) |entry| {
            const key = entry.key_ptr.*;
            const node = entry.value_ptr.*;
            if (std.mem.eql(u8, key, "root") or std.mem.eql(u8, key, "glob") or std.mem.eql(u8, key, "suffix")) {
                if (node != .string or node.string.len == 0) return Error.ConfigRange;
                selections += 1;
                const stored = try buffer.store(node.string);
                if (std.mem.eql(u8, key, "root")) rule.root = stored;
                if (std.mem.eql(u8, key, "glob")) rule.glob = stored;
                if (std.mem.eql(u8, key, "suffix")) rule.suffix = stored;
                continue;
            }
            if (std.mem.eql(u8, key, "reason")) {
                if (node != .string or node.string.len == 0) return Error.ConfigRange;
                rule.reason = try buffer.store(node.string);
                continue;
            }
            return Error.ConfigUnknownKey;
        }
        if (selections != 1) return Error.ConfigSchema;
        if (config.manual_only_rules_len >= manual_only_rules_max) return Error.ConfigCapacity;
        config.manual_only_rules[config.manual_only_rules_len] = rule;
        config.manual_only_rules_len += 1;
    }
}

fn parseThresholds(value: std.json.Value, config: *Config) Error!void {
    if (value != .object) return Error.ConfigType;
    var seen = [1]bool{false} ** threshold_keys.len;
    var iterator = value.object.iterator();
    while (iterator.next()) |entry| {
        const key = entry.key_ptr.*;
        const known = keyIndex(&threshold_keys, key) orelse return Error.ConfigUnknownKey;
        if (seen[known]) return Error.ConfigSyntax;
        seen[known] = true;
        const node = entry.value_ptr.*;
        if (node != .integer or node.integer < 0) return Error.ConfigRange;
        const number: u64 = @intCast(node.integer);
        if (std.mem.eql(u8, key, "large_file_bytes")) config.thresholds.large_file_bytes = number;
        if (std.mem.eql(u8, key, "large_directory_bytes")) config.thresholds.large_directory_bytes = number;
        if (std.mem.eql(u8, key, "old_seconds")) config.thresholds.old_seconds = number;
        if (std.mem.eql(u8, key, "stale_temporary_seconds")) config.thresholds.stale_temporary_seconds = number;
        if (std.mem.eql(u8, key, "stale_trash_seconds")) config.thresholds.stale_trash_seconds = number;
        if (std.mem.eql(u8, key, "old_rotated_log_seconds")) config.thresholds.old_rotated_log_seconds = number;
    }
}

/// Version 1 ships no compiled adapter identifiers yet, so every adapter key
/// is unknown rather than a silently ignored misspelling.
fn parseAdapters(value: std.json.Value) Error!void {
    if (value != .object) return Error.ConfigType;
    if (value.object.count() != 0) return Error.ConfigUnknownKey;
}

fn parseNetwork(value: std.json.Value, config: *Config) Error!void {
    if (value != .object) return Error.ConfigType;
    var seen_offline = false;
    var seen_disabled = false;
    var iterator = value.object.iterator();
    while (iterator.next()) |entry| {
        const key = entry.key_ptr.*;
        const node = entry.value_ptr.*;
        if (std.mem.eql(u8, key, "offline")) {
            if (seen_offline) return Error.ConfigSyntax;
            seen_offline = true;
            if (node != .bool) return Error.ConfigType;
            config.offline = node.bool;
            continue;
        }
        if (std.mem.eql(u8, key, "disabled_adapters")) {
            if (seen_disabled) return Error.ConfigSyntax;
            seen_disabled = true;
            if (node != .array) return Error.ConfigType;
            for (node.array.items) |item| {
                if (item != .string) return Error.ConfigType;
                return Error.ConfigUnknownKey; // no compiled adapters exist
            }
            continue;
        }
        return Error.ConfigUnknownKey;
    }
}

fn parseDisplay(value: std.json.Value, config: *Config) Error!void {
    if (value != .object) return Error.ConfigType;
    var seen_color = false;
    var seen_ascii = false;
    var iterator = value.object.iterator();
    while (iterator.next()) |entry| {
        const key = entry.key_ptr.*;
        const node = entry.value_ptr.*;
        if (std.mem.eql(u8, key, "color")) {
            if (seen_color) return Error.ConfigSyntax;
            seen_color = true;
            if (node != .bool) return Error.ConfigType;
            config.color = node.bool;
            continue;
        }
        if (std.mem.eql(u8, key, "ascii")) {
            if (seen_ascii) return Error.ConfigSyntax;
            seen_ascii = true;
            if (node != .bool) return Error.ConfigType;
            config.ascii = node.bool;
            continue;
        }
        return Error.ConfigUnknownKey;
    }
}