repositories / termux-janitor
termux-janitor
Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.
owned by admin
tools/janitor/validate.zig
Rawconst std = @import("std");
const engine = @import("engine");
const model_types = @import("model.zig");
const profile = @import("profile");
const diag = engine.diag;
const fail = diag.fail;
const on_limit: engine.diag.Subject = .{
.code = "TJSP-P010",
.kind = "limit",
.owner = model_types.limits_path,
.inspect = &.{profile.tool_invocation ++ " query limit ID"},
.resolve = "Correct the limit record in its registry; limits own no prose duplicate.",
};
const on_cli: engine.diag.Subject = .{
.code = "TJSP-P020",
.kind = "command-line definition",
.owner = model_types.cli_path,
.inspect = &.{profile.tool_invocation ++ " query cli-option ID"},
.resolve = "Correct the command-line registry; help text and the manual are generated from it.",
};
const on_package: engine.diag.Subject = .{
.code = "TJSP-P030",
.kind = "package version",
.owner = model_types.package_path,
.inspect = &.{profile.tool_invocation ++ " check"},
.resolve = "Correct the version in the package manifest; it is the one canonical version.",
};
const on_android: engine.diag.Subject = .{
.code = "TJSP-P040",
.kind = "Android package allowlist",
.owner = model_types.android_packages_path,
.inspect = &.{profile.tool_invocation ++ " query android-package ID"},
.resolve = "Correct the Android package registry; adapter behavior remains authoritative prose.",
};
const on_classification: engine.diag.Subject = .{
.code = "TJSP-P050",
.kind = "classification catalog",
.owner = model_types.classification_path,
.inspect = &.{profile.tool_invocation ++ " query classification-rule ID"},
.resolve = "Correct the classification catalog; classification behavior remains authoritative prose.",
};
const on_threshold: engine.diag.Subject = .{
.code = "TJSP-P060",
.kind = "classification thresholds",
.owner = model_types.thresholds_path,
.inspect = &.{profile.tool_invocation ++ " query threshold ID"},
.resolve = "Correct the threshold registry; threshold semantics remain authoritative prose.",
};
const text = engine.text;
/// Validate the complete repository model: generic graph plus product registries.
pub fn validateModel(
allocator: std.mem.Allocator,
io: std.Io,
model: *const model_types.Model,
stderr: *std.Io.Writer,
) !void {
try engine.validate.graph(allocator, io, &model.graph, stderr);
try validateLimits(model.limits, stderr);
try validateCli(&model.cli, stderr);
try validateAndroidPackages(model.android_packages, &model.graph, stderr);
try validateClassification(&model.classification, &model.graph, stderr);
try validateThresholds(model.thresholds, &model.graph, stderr);
try validatePackage(&model.package, stderr);
}
pub fn validateAndroidPackages(
packages: []const model_types.AndroidPackage,
graph: *const engine.model.Graph,
stderr: *std.Io.Writer,
) !void {
if (packages.len == 0) return fail(stderr, on_android, "allowlist is empty", .{});
if (packages.len > model_types.android_package_count_max) {
return fail(stderr, on_android, "allowlist exceeds {d} entries", .{
model_types.android_package_count_max,
});
}
var base_count: u32 = 0;
for (packages, 0..) |package, index| {
if (!text.symbolValid(package.id)) {
return fail(stderr, on_android, "invalid package ID: {s}", .{package.id});
}
if (package.revision == 0) {
return fail(stderr, on_android, "zero package revision: {s}", .{package.id});
}
if (!androidIdentifierValid(package.identifier)) {
return fail(stderr, on_android, "invalid package identifier: {s}", .{package.identifier});
}
if (!std.mem.eql(u8, package.requirement.id, "TJ-ADAPT-09") or
package.requirement.revision != 1)
{
return fail(stderr, on_android, "package has wrong requirement: {s}", .{package.id});
}
if (!requirementRevisionExists(graph, package.requirement)) {
return fail(stderr, on_android, "package references unknown requirement: {s}", .{
package.id,
});
}
if (package.role == .base) base_count += 1;
for (packages[0..index]) |previous| {
if (std.mem.eql(u8, previous.id, package.id)) {
return fail(stderr, on_android, "duplicate package ID: {s}", .{package.id});
}
if (std.mem.eql(u8, previous.identifier, package.identifier)) {
return fail(stderr, on_android, "duplicate package identifier: {s}", .{
package.identifier,
});
}
}
}
if (base_count != 1) return fail(stderr, on_android, "allowlist must have one base package", .{});
for (packages) |package| {
if (package.role == .base and !std.mem.eql(u8, package.identifier, "com.termux")) {
return fail(stderr, on_android, "base package must be com.termux", .{});
}
if (package.role == .add_on and std.mem.eql(u8, package.identifier, "com.termux")) {
return fail(stderr, on_android, "com.termux cannot be an add-on", .{});
}
}
}
fn androidIdentifierValid(identifier: []const u8) bool {
if (identifier.len < "com.termux".len) return false;
if (!std.mem.startsWith(u8, identifier, "com.termux")) return false;
if (identifier.len == "com.termux".len) return true;
if (identifier["com.termux".len] != '.') return false;
var previous_dot = false;
for (identifier) |byte| {
if (byte == '.') {
if (previous_dot) return false;
previous_dot = true;
continue;
}
if (!std.ascii.isLower(byte) and !std.ascii.isDigit(byte)) return false;
previous_dot = false;
}
return !previous_dot;
}
fn requirementRevisionExists(
graph: *const engine.model.Graph,
reference: engine.model.RequirementReference,
) bool {
for (graph.groups) |group| for (group.requirements) |requirement| {
if (std.mem.eql(u8, requirement.id, reference.id)) {
return requirement.revision == reference.revision;
}
};
return false;
}
/// The canonical version also names release inputs, so it stays inside the
/// artifact filename charset owned by `spec/ARTIFACT.md`.
pub fn validateClassification(
classification: *const model_types.Classification,
graph: *const engine.model.Graph,
stderr: *std.Io.Writer,
) !void {
try validateClassificationSet(
&classification.source_basenames,
"TJ-CLASS-03",
graph,
stderr,
);
try validateClassificationSet(
&classification.source_suffixes,
"TJ-CLASS-03",
graph,
stderr,
);
try validateClassificationSet(
&classification.document_roots,
"TJ-CLASS-04",
graph,
stderr,
);
}
fn validateClassificationSet(
set: *const model_types.ClassificationSet,
requirement_id: []const u8,
graph: *const engine.model.Graph,
stderr: *std.Io.Writer,
) !void {
if (set.revision == 0) return fail(stderr, on_classification, "zero catalog revision", .{});
if (set.values.len == 0) return fail(stderr, on_classification, "empty catalog", .{});
if (set.values.len > model_types.classification_value_count_max) {
return fail(stderr, on_classification, "catalog exceeds {d} values", .{
model_types.classification_value_count_max,
});
}
if (!std.mem.eql(u8, set.requirement.id, requirement_id) or
set.requirement.revision != 1)
{
return fail(stderr, on_classification, "catalog has wrong requirement", .{});
}
if (!requirementRevisionExists(graph, set.requirement)) {
return fail(stderr, on_classification, "catalog requirement is unknown", .{});
}
for (set.values, 0..) |value, index| {
if (!classificationValueValid(value)) {
return fail(stderr, on_classification, "invalid catalog value: {s}", .{value});
}
for (set.values[0..index]) |previous| {
if (std.mem.eql(u8, previous, value)) {
return fail(stderr, on_classification, "duplicate catalog value: {s}", .{value});
}
}
}
}
fn classificationValueValid(value: []const u8) bool {
if (value.len == 0) return false;
for (value) |byte| if (byte == '\n' or byte == '\r' or byte == '/') return false;
return true;
}
pub fn validateThresholds(
thresholds: []const model_types.Threshold,
graph: *const engine.model.Graph,
stderr: *std.Io.Writer,
) !void {
if (thresholds.len == 0) return fail(stderr, on_threshold, "threshold registry is empty", .{});
if (thresholds.len > model_types.threshold_count_max) {
return fail(stderr, on_threshold, "threshold count exceeds {d}", .{
model_types.threshold_count_max,
});
}
for (thresholds, 0..) |threshold, index| {
if (!text.symbolValid(threshold.id) or !text.symbolValid(threshold.key)) {
return fail(stderr, on_threshold, "invalid threshold identity: {s}", .{threshold.id});
}
if (threshold.revision == 0 or threshold.value == 0) {
return fail(stderr, on_threshold, "invalid threshold value: {s}", .{threshold.id});
}
if (!std.mem.eql(u8, threshold.requirement.id, "TJ-CLASS-14") or
threshold.requirement.revision != 1 or
!requirementRevisionExists(graph, threshold.requirement))
{
return fail(stderr, on_threshold, "invalid threshold requirement: {s}", .{threshold.id});
}
for (thresholds[0..index]) |previous| {
if (std.mem.eql(u8, previous.id, threshold.id) or
std.mem.eql(u8, previous.key, threshold.key))
{
return fail(stderr, on_threshold, "duplicate threshold: {s}", .{threshold.id});
}
}
}
const file = thresholdValue(thresholds, "large_file_bytes") orelse {
return fail(stderr, on_threshold, "missing large_file_bytes threshold", .{});
};
const directory = thresholdValue(thresholds, "large_directory_bytes") orelse {
return fail(stderr, on_threshold, "missing large_directory_bytes threshold", .{});
};
if (directory.value < file.value) {
return fail(stderr, on_threshold, "directory threshold is below file threshold", .{});
}
}
fn thresholdValue(
thresholds: []const model_types.Threshold,
key: []const u8,
) ?model_types.Threshold {
for (thresholds) |threshold| if (std.mem.eql(u8, threshold.key, key)) return threshold;
return null;
}
/// The canonical version also names release inputs, so it stays inside the
/// artifact filename charset owned by `spec/ARTIFACT.md`.
pub fn validatePackage(package: *const model_types.Package, stderr: *std.Io.Writer) !void {
if (package.version.len == 0) return fail(stderr, on_package, "version is empty", .{});
for (package.version) |byte| {
const permitted = std.ascii.isAlphanumeric(byte) or
byte == '.' or byte == '-' or byte == '_' or byte == '+';
if (!permitted) {
return fail(stderr, on_package, "version byte is not permitted: {s}", .{
package.version,
});
}
}
}
pub fn validateLimits(limits: []const model_types.Limit, stderr: *std.Io.Writer) !void {
if (limits.len == 0) return fail(stderr, on_limit, "limit registry is empty", .{});
if (limits.len > model_types.limit_count_max) {
return fail(stderr, on_limit, "limit count exceeds {d}", .{model_types.limit_count_max});
}
for (limits, 0..) |limit, index| {
if (!text.symbolValid(limit.id)) {
return fail(stderr, on_limit, "invalid limit ID: {s}", .{limit.id});
}
if (limit.label.len == 0) {
return fail(stderr, on_limit, "empty limit label: {s}", .{limit.id});
}
for (limits[0..index]) |previous| {
if (std.mem.eql(u8, previous.id, limit.id)) {
return fail(stderr, on_limit, "duplicate limit ID: {s}", .{limit.id});
}
}
switch (limit.value) {
.scalar => |scalar| if (scalar.value == 0) {
return fail(stderr, on_limit, "zero limit: {s}", .{limit.id});
},
.dimensions => |dimensions| if (dimensions.columns == 0 or dimensions.rows == 0) {
return fail(stderr, on_limit, "zero dimensions: {s}", .{limit.id});
},
.alternative => |alternative| {
if (alternative.items == 0) {
return fail(stderr, on_limit, "zero alternative item bound: {s}", .{limit.id});
}
if (alternative.milliseconds == 0) {
return fail(stderr, on_limit, "zero alternative time bound: {s}", .{limit.id});
}
},
}
}
}
pub fn validateCli(cli: *const model_types.Cli, stderr: *std.Io.Writer) !void {
if (cli.executable.len == 0) return fail(stderr, on_cli, "CLI executable is empty", .{});
if (cli.description.len == 0) return fail(stderr, on_cli, "CLI description is empty", .{});
if (cli.options.len == 0) return fail(stderr, on_cli, "CLI option registry is empty", .{});
if (cli.options.len > model_types.cli_option_count_max) {
return fail(
stderr,
on_cli,
"CLI option count exceeds {d}",
.{model_types.cli_option_count_max},
);
}
if (cli.exit_statuses.len == 0) return fail(stderr, on_cli, "CLI exit statuses are empty", .{});
if (cli.exit_statuses.len > model_types.cli_exit_status_count_max) {
return fail(
stderr,
on_cli,
"CLI exit status count exceeds {d}",
.{model_types.cli_exit_status_count_max},
);
}
try validateCliOptions(cli.options, stderr);
try validateCliRules(cli, stderr);
try validateCliStatuses(cli.exit_statuses, stderr);
}
pub fn validateCliOptions(options: []const model_types.CliOption, stderr: *std.Io.Writer) !void {
for (options, 0..) |option, index| {
if (!text.symbolValid(option.id)) {
return fail(stderr, on_cli, "invalid option ID: {s}", .{option.id});
}
if (!std.mem.startsWith(u8, option.spelling, "--") or option.spelling.len == 2) {
return fail(stderr, on_cli, "invalid long option: {s}", .{option.spelling});
}
if (option.summary.len == 0) {
return fail(stderr, on_cli, "empty option summary: {s}", .{option.id});
}
for (option.bypasses_startup, 0..) |subsystem, subsystem_index| {
for (option.bypasses_startup[0..subsystem_index]) |previous| {
if (subsystem == previous) {
return fail(stderr, on_cli, "duplicate startup bypass: {s}", .{option.id});
}
}
}
for (options[0..index]) |previous| {
if (std.mem.eql(u8, previous.id, option.id)) {
return fail(stderr, on_cli, "duplicate option ID: {s}", .{option.id});
}
if (std.mem.eql(u8, previous.spelling, option.spelling)) {
return fail(stderr, on_cli, "duplicate option spelling: {s}", .{option.spelling});
}
}
}
}
pub fn validateCliRules(cli: *const model_types.Cli, stderr: *std.Io.Writer) !void {
for (cli.argument_rules, 0..) |rule, index| {
const option = findCliOption(cli.options, rule.option) orelse {
return fail(
stderr,
on_cli,
"argument rule references unknown option: {s}",
.{rule.option},
);
};
if (option.argument == .none) {
return fail(
stderr,
on_cli,
"argument rule references valueless option: {s}",
.{rule.option},
);
}
if (rule.argument.len == 0) {
return fail(stderr, on_cli, "argument rule has no display name: {s}", .{rule.option});
}
if (rule.arity == 0) {
return fail(stderr, on_cli, "argument rule has zero arity: {s}", .{rule.option});
}
for (cli.argument_rules[0..index]) |previous| {
if (std.mem.eql(u8, previous.option, rule.option)) {
return fail(stderr, on_cli, "duplicate argument rule: {s}", .{rule.option});
}
}
}
}
pub fn validateCliStatuses(
statuses: []const model_types.CliExitStatus,
stderr: *std.Io.Writer,
) !void {
for (statuses, 0..) |status, index| {
if (!text.symbolValid(status.id)) {
return fail(stderr, on_cli, "invalid status ID: {s}", .{status.id});
}
if (status.condition.len == 0) {
return fail(stderr, on_cli, "empty status condition: {s}", .{status.id});
}
for (statuses[0..index]) |previous| {
if (std.mem.eql(u8, previous.id, status.id)) {
return fail(stderr, on_cli, "duplicate status ID: {s}", .{status.id});
}
if (previous.code == status.code) {
return fail(stderr, on_cli, "duplicate status code: {d}", .{status.code});
}
}
}
}
pub fn findCliOption(
options: []const model_types.CliOption,
id: []const u8,
) ?model_types.CliOption {
for (options) |option| if (std.mem.eql(u8, option.id, id)) return option;
return null;
}
test "package validation rejects an empty or out-of-charset version" {
var output = std.Io.Writer.Allocating.init(std.testing.allocator);
defer output.deinit();
try validatePackage(&.{ .version = "1.2.3-rc.1+build_2" }, &output.writer);
try std.testing.expectError(
error.InvalidSpecification,
validatePackage(&.{ .version = "" }, &output.writer),
);
try std.testing.expectError(
error.InvalidSpecification,
validatePackage(&.{ .version = "1.0.0 beta" }, &output.writer),
);
}
test "limit validation rejects zero and duplicate identities" {
const valid: model_types.Limit = .{
.id = "items",
.category = .scan,
.label = "Items",
.value = .{ .scalar = .{ .value = 1, .unit = .count } },
};
const duplicate = [_]model_types.Limit{ valid, valid };
var output = std.Io.Writer.Allocating.init(std.testing.allocator);
defer output.deinit();
try std.testing.expectError(
error.InvalidSpecification,
validateLimits(&duplicate, &output.writer),
);
const zero = [_]model_types.Limit{.{
.id = "items",
.category = .scan,
.label = "Items",
.value = .{ .scalar = .{ .value = 0, .unit = .count } },
}};
try std.testing.expectError(
error.InvalidSpecification,
validateLimits(&zero, &output.writer),
);
}
test "Android package validation enforces one base and exact ownership" {
const requirements = [_]engine.model.Requirement{.{
.id = "TJ-ADAPT-09",
.revision = 1,
.status = .accepted,
.label = "Android allowlist and confirmation",
.owner = "spec/ADAPTERS.md#android-allowlist",
.suites = &.{.u},
.limitation_status = .unassessed,
}};
const groups = [_]engine.model.RequirementGroup{.{
.title = "Adapters",
.requirements = &requirements,
}};
const graph: engine.model.Graph = .{
.documents = &.{},
.concepts = &.{},
.groups = &groups,
.test_specs = &.{},
.test_bindings = &.{},
.verification = &.{},
.implementations = &.{},
};
const packages = [_]model_types.AndroidPackage{
.{
.id = "android_termux",
.revision = 1,
.identifier = "com.termux",
.role = .base,
.requirement = .{ .id = "TJ-ADAPT-09", .revision = 1 },
},
.{
.id = "android_termux_api",
.revision = 1,
.identifier = "com.termux.api",
.role = .add_on,
.requirement = .{ .id = "TJ-ADAPT-09", .revision = 1 },
},
};
var output = std.Io.Writer.Allocating.init(std.testing.allocator);
defer output.deinit();
try validateAndroidPackages(&packages, &graph, &output.writer);
var invalid = packages;
invalid[1].role = .base;
try std.testing.expectError(
error.InvalidSpecification,
validateAndroidPackages(&invalid, &graph, &output.writer),
);
}
test "CLI validation rejects an unknown argument-rule option" {
const options = [_]model_types.CliOption{.{
.id = "help",
.spelling = "--help",
.argument = .none,
.summary = "Help.",
.must_appear_alone = true,
.bypasses_startup = &.{},
.requires_terminal = false,
}};
const rules = [_]model_types.CliArgumentRule{.{
.option = "missing",
.argument = "PATH",
.arity = 1,
.syntax = .separate,
.nonempty = true,
.relative_base = .initial_working_directory,
}};
const statuses = [_]model_types.CliExitStatus{.{
.id = "success",
.code = 0,
.condition = "Success.",
}};
const cli: model_types.Cli = .{
.executable = "termux-janitor",
.description = "Test executable.",
.default_mode = "test",
.long_options_only = true,
.option_terminator = true,
.operands_allowed = false,
.duplicate_options_allowed = false,
.unattended_cleanup = false,
.informational_stream = .stdout,
.usage_stream = .stderr,
.diagnostic_stream = .stderr,
.options = &options,
.argument_rules = &rules,
.exit_statuses = &statuses,
};
var output = std.Io.Writer.Allocating.init(std.testing.allocator);
defer output.deinit();
try std.testing.expectError(
error.InvalidSpecification,
validateCli(&cli, &output.writer),
);
}