const 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), ); }