Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

tools/janitor/validate.zig

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