//! 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] == '/'; } /// `/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; } }