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