Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

tools/spec-engine/src/evidence.zig

Raw
//! Deterministic suite-evidence records and their join to the frozen graph.
//!
//! A suite record is a bounded, transient JSON artifact written once by the
//! build graph after a fresh, successful run of one generated suite binary.
//! It freezes the graph, binding, fixture, artifact, toolchain, and option
//! identities that produced the run. The engine owns the record format and
//! the deterministic join to a set of expected identities; the profile owns
//! where records are stored and how suites execute. A record is evidence of
//! suite execution only: it never equates suite success with requirement
//! acceptance, never covers unregistered work, and a missing record is never
//! a failure verdict.
const std = @import("std");

/// Bumped only by a contract revision in `spec/ENGINE.md`.
pub const schema_version: u32 = 1;

/// Hard bound on one encoded suite record.
pub const record_size_max: usize = 16 * 1024;

/// Hard bound on identities carried by one record.
pub const test_specs_max: usize = 64;

/// The one admitted outcome. The build graph writes records only after a
/// fresh, successful suite run, so failures and interruptions produce no
/// success record.
pub const outcome_passed = "passed";

pub const SpecIdentity = struct {
    id: []const u8,
    revision: u32,
    fixture_id: []const u8,
    fixture_revision: u32,
    fixture_sha256: []const u8,
    binding_source: []const u8,
    binding_sha256: []const u8,

    pub fn eql(a: SpecIdentity, b: SpecIdentity) bool {
        return a.revision == b.revision and a.fixture_revision == b.fixture_revision and
            std.mem.eql(u8, a.id, b.id) and
            std.mem.eql(u8, a.fixture_id, b.fixture_id) and
            std.mem.eql(u8, a.fixture_sha256, b.fixture_sha256) and
            std.mem.eql(u8, a.binding_source, b.binding_source) and
            std.mem.eql(u8, a.binding_sha256, b.binding_sha256);
    }
};

pub const SuiteRecord = struct {
    schema_version: u32,
    suite: []const u8,
    outcome: []const u8,
    zig: []const u8,
    target: []const u8,
    optimize: []const u8,
    suite_filter: []const u8,
    artifact_sha256: []const u8,
    test_specs: []SpecIdentity,
};

/// Find one stored identity by test-specification identifier.
fn identityFor(
    record: *const SuiteRecord,
    id: []const u8,
) ?*const SpecIdentity {
    for (record.test_specs) |*candidate| {
        if (std.mem.eql(u8, candidate.id, id)) return candidate;
    }
    return null;
}

/// One parsed record together with the suite name it was stored under.
pub const NamedRecord = struct {
    suite: []const u8,
    record: SuiteRecord,
};

/// Derived state for one expected identity.
pub const SpecState = enum {
    /// A record from the exact revisions exists and recorded success.
    passed,
    /// A record exists for the suite, but not for these exact revisions.
    superseded,
    /// No record exists for the suite. Never a failure verdict.
    not_executed,
};

pub const Row = struct {
    identity: SpecIdentity,
    suite: []const u8,
    state: SpecState,
};

pub const ParseError = error{
    RecordTooLarge,
    InvalidRecordSchemaVersion,
    InvalidRecordOutcome,
    InvalidRecordSuite,
    InvalidRecordIdentity,
    InvalidRecordHash,
    UnsortedRecordIdentities,
    DuplicateRecordIdentity,
    TooManyRecordIdentities,
    InvalidRecordJson,
    OutOfMemory,
};

/// A parsed record together with the arena that owns its identity array.
/// Identity strings borrow from the encoded bytes; the bytes must outlive the
/// parsed record.
pub const ParsedRecord = struct {
    arena: std.heap.ArenaAllocator,
    record: SuiteRecord,

    pub fn deinit(parsed: *ParsedRecord) void {
        parsed.arena.deinit();
        parsed.* = undefined;
    }
};

/// Parse and fully validate one encoded record. The returned record
/// references `bytes`; the bytes must outlive the parsed record.
pub fn parseRecord(
    allocator: std.mem.Allocator,
    bytes: []const u8,
) (ParseError || error{InvalidRecordJson})!ParsedRecord {
    if (bytes.len == 0 or bytes.len > record_size_max) return error.RecordTooLarge;
    var arena = std.heap.ArenaAllocator.init(allocator);
    errdefer arena.deinit();
    const parsed = std.json.parseFromSliceLeaky(
        WireRecord,
        arena.allocator(),
        bytes,
        .{},
    ) catch return error.InvalidRecordJson;
    if (parsed.schema_version != schema_version) return error.InvalidRecordSchemaVersion;
    if (parsed.outcome.len == 0 or !std.mem.eql(u8, parsed.outcome, outcome_passed)) {
        return error.InvalidRecordOutcome;
    }
    if (parsed.suite.len == 0) return error.InvalidRecordSuite;
    if (parsed.artifact_sha256.len != 64 or !isLowerHex(parsed.artifact_sha256)) {
        return error.InvalidRecordHash;
    }
    if (parsed.test_specs.len == 0 or parsed.test_specs.len > test_specs_max) {
        return error.TooManyRecordIdentities;
    }
    for (parsed.test_specs, 0..) |*identity, index| {
        if (identity.id.len == 0 or identity.fixture_id.len == 0 or
            identity.binding_source.len == 0)
        {
            return error.InvalidRecordIdentity;
        }
        if (identity.fixture_sha256.len != 64 or !isLowerHex(identity.fixture_sha256)) {
            return error.InvalidRecordHash;
        }
        if (identity.binding_sha256.len != 64 or !isLowerHex(identity.binding_sha256)) {
            return error.InvalidRecordHash;
        }
        if (index == 0) continue;
        const previous = parsed.test_specs[index - 1].id;
        switch (std.mem.order(u8, previous, identity.id)) {
            .lt => {},
            .eq => return error.DuplicateRecordIdentity,
            .gt => return error.UnsortedRecordIdentities,
        }
    }
    return .{
        .arena = arena,
        .record = .{
            .schema_version = parsed.schema_version,
            .suite = parsed.suite,
            .outcome = parsed.outcome,
            .zig = parsed.zig,
            .target = parsed.target,
            .optimize = parsed.optimize,
            .suite_filter = parsed.suite_filter,
            .artifact_sha256 = parsed.artifact_sha256,
            .test_specs = parsed.test_specs,
        },
    };
}

const WireRecord = struct {
    schema_version: u32,
    suite: []const u8,
    outcome: []const u8,
    zig: []const u8,
    target: []const u8,
    optimize: []const u8,
    suite_filter: []const u8 = "",
    artifact_sha256: []const u8,
    test_specs: []SpecIdentity,
};

/// Encode one record canonically. Field order, identity order, and escaping
/// are deterministic for equal records.
pub fn renderRecord(record: *const SuiteRecord, writer: *std.Io.Writer) !void {
    try writer.writeAll("{\"schema_version\":");
    try writer.print("{d}", .{record.schema_version});
    try writer.writeAll(",\"suite\":");
    try encodeString(record.suite, writer);
    try writer.writeAll(",\"outcome\":");
    try encodeString(record.outcome, writer);
    try writer.writeAll(",\"zig\":");
    try encodeString(record.zig, writer);
    try writer.writeAll(",\"target\":");
    try encodeString(record.target, writer);
    try writer.writeAll(",\"optimize\":");
    try encodeString(record.optimize, writer);
    try writer.writeAll(",\"suite_filter\":");
    try encodeString(record.suite_filter, writer);
    try writer.writeAll(",\"artifact_sha256\":");
    try encodeString(record.artifact_sha256, writer);
    try writer.writeAll(",\"test_specs\":[");
    for (record.test_specs, 0..) |*identity, index| {
        if (index > 0) try writer.writeByte(',');
        try writer.writeAll("{\"id\":");
        try encodeString(identity.id, writer);
        try writer.print(",\"revision\":{d}", .{identity.revision});
        try writer.writeAll(",\"fixture_id\":");
        try encodeString(identity.fixture_id, writer);
        try writer.print(",\"fixture_revision\":{d}", .{identity.fixture_revision});
        try writer.writeAll(",\"fixture_sha256\":");
        try encodeString(identity.fixture_sha256, writer);
        try writer.writeAll(",\"binding_source\":");
        try encodeString(identity.binding_source, writer);
        try writer.writeAll(",\"binding_sha256\":");
        try encodeString(identity.binding_sha256, writer);
        try writer.writeByte('}');
    }
    try writer.writeAll("]}\n");
}

fn encodeString(value: []const u8, writer: *std.Io.Writer) !void {
    try std.json.Stringify.encodeJsonString(value, .{}, writer);
}

/// Join a set of expected identities against at most one record per suite.
/// The join is deterministic: equal inputs always produce equal rows in the
/// same order. Records never add rows, and a missing record yields
/// `.not_executed`, never a failure. When a record exists for the suite, an
/// expected identity absent from the record is `.superseded` because the
/// recorded run predates the graph work.
pub fn join(
    expected: []const ExpectedIdentity,
    records: []const NamedRecord,
    allocator: std.mem.Allocator,
) ![]Row {
    var rows = try allocator.alloc(Row, expected.len);
    errdefer allocator.free(rows);
    for (expected, 0..) |expected_identity, index| {
        var state: SpecState = .not_executed;
        for (records) |*named| {
            if (!std.mem.eql(u8, named.suite, expected_identity.suite)) continue;
            const actual = identityFor(&named.record, expected_identity.identity.id);
            state = if (actual) |candidate|
                (if (SpecIdentity.eql(candidate.*, expected_identity.identity))
                    SpecState.passed
                else
                    SpecState.superseded)
            else
                SpecState.superseded;
            break;
        }
        rows[index] = .{
            .identity = expected_identity.identity,
            .suite = expected_identity.suite,
            .state = state,
        };
    }
    return rows;
}

pub const ExpectedIdentity = struct {
    suite: []const u8,
    identity: SpecIdentity,
};

fn isLowerHex(value: []const u8) bool {
    for (value) |c| {
        const digit = c >= '0' and c <= '9';
        const letter = c >= 'a' and c <= 'f';
        if (!digit and !letter) return false;
    }
    return true;
}

test "record parse and render round trip is exact" {
    const encoded =
        \\{"schema_version":1,"suite":"u","outcome":"passed","zig":"0.16.0",
        \\"target":"aarch64-linux-android","optimize":"Debug","suite_filter":"",
        \\"artifact_sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        \\"test_specs":[{"id":"test-a","revision":2,"fixture_id":"fixture-a",
        \\"fixture_revision":2,
        \\"fixture_sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        \\"binding_source":"tests/fixtures/a.zig",
        \\"binding_sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}]}
        \\
    ;
    var parsed = try parseRecord(std.testing.allocator, encoded);
    defer parsed.deinit();
    const record = &parsed.record;
    try std.testing.expectEqual(@as(u32, 1), record.schema_version);
    try std.testing.expectEqual(@as(usize, 1), record.test_specs.len);
    try std.testing.expectEqualStrings("u", record.suite);

    var buffer: [record_size_max]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buffer);
    try renderRecord(record, &writer);
    var reparsed = try parseRecord(std.testing.allocator, writer.buffered());
    defer reparsed.deinit();
    try std.testing.expect(identityFor(&reparsed.record, "test-a") != null);
    try std.testing.expectEqual(
        @as(u32, 2),
        identityFor(&reparsed.record, "test-a").?.revision,
    );
}

test "record parse rejects schema drift, unknown outcome, and bad hashes" {
    const sha = "01" ** 32;
    const bad_version = std.fmt.comptimePrint(
        "{{\"schema_version\":2,\"suite\":\"u\",\"outcome\":\"passed\",\"zig\":\"0\",\"target\":\"t\",\"optimize\":\"Debug\",\"artifact_sha256\":\"{s}\",\"test_specs\":[{{\"id\":\"test-a\",\"revision\":1,\"fixture_id\":\"f\",\"fixture_revision\":1,\"fixture_sha256\":\"{s}\",\"binding_source\":\"a.zig\",\"binding_sha256\":\"{s}\"}}]}}",
        .{ sha, sha, sha },
    );
    const bad_versions = [_][]const u8{bad_version};
    for (bad_versions) |encoded| {
        try std.testing.expectError(
            error.InvalidRecordSchemaVersion,
            parseRecord(std.testing.allocator, encoded),
        );
    }
    try std.testing.expectError(error.InvalidRecordOutcome, parseRecord(
        std.testing.allocator,
        std.fmt.comptimePrint(
            "{{\"schema_version\":1,\"suite\":\"u\",\"outcome\":\"claimed\",\"zig\":\"0\",\"target\":\"t\",\"optimize\":\"Debug\",\"artifact_sha256\":\"{s}\",\"test_specs\":[{{\"id\":\"test-a\",\"revision\":1,\"fixture_id\":\"f\",\"fixture_revision\":1,\"fixture_sha256\":\"{s}\",\"binding_source\":\"a.zig\",\"binding_sha256\":\"{s}\"}}]}}",
            .{ sha, sha, sha },
        ),
    ));
    try std.testing.expectError(error.InvalidRecordHash, parseRecord(
        std.testing.allocator,
        "{\"schema_version\":1,\"suite\":\"u\",\"outcome\":\"passed\",\"zig\":\"0\",\"target\":\"t\",\"optimize\":\"Debug\",\"artifact_sha256\":\"XYZ\",\"test_specs\":[{\"id\":\"test-a\",\"revision\":1,\"fixture_id\":\"f\",\"fixture_revision\":1,\"fixture_sha256\":\"XYZ\",\"binding_source\":\"a.zig\",\"binding_sha256\":\"XYZ\"}]}",
    ));
    try std.testing.expectError(error.RecordTooLarge, parseRecord(std.testing.allocator, ""));
}

test "join is deterministic and never invents or blames" {
    const identity_a = SpecIdentity{
        .id = "test-a",
        .revision = 1,
        .fixture_id = "fixture-a",
        .fixture_revision = 1,
        .fixture_sha256 = "aa" ** 32,
        .binding_source = "tests/fixtures/a.zig",
        .binding_sha256 = "bb" ** 32,
    };
    const identity_a_stale = SpecIdentity{
        .revision = 2,
        .fixture_sha256 = "cc" ** 32,
        .id = "test-a",
        .fixture_id = "fixture-a",
        .fixture_revision = 1,
        .binding_source = "tests/fixtures/a.zig",
        .binding_sha256 = "bb" ** 32,
    };
    const identity_b = SpecIdentity{
        .id = "test-b",
        .revision = 1,
        .fixture_id = "fixture-b",
        .fixture_revision = 1,
        .fixture_sha256 = "aa" ** 32,
        .binding_source = "tests/fixtures/b.zig",
        .binding_sha256 = "bb" ** 32,
    };
    const record = SuiteRecord{
        .schema_version = 1,
        .suite = "u",
        .outcome = outcome_passed,
        .zig = "0.16.0",
        .target = "native",
        .optimize = "Debug",
        .suite_filter = "",
        .artifact_sha256 = "ab" ** 32,
        .test_specs = @constCast(&[_]SpecIdentity{identity_a}),
    };
    const records = [_]NamedRecord{.{ .suite = "u", .record = record }};

    const expected = [_]ExpectedIdentity{
        .{ .suite = "u", .identity = identity_a },
        .{ .suite = "u", .identity = identity_a_stale },
        .{ .suite = "sm", .identity = identity_b },
    };
    const rows = try join(&expected, &records, std.testing.allocator);
    defer std.testing.allocator.free(rows);
    try std.testing.expectEqual(SpecState.passed, rows[0].state);
    try std.testing.expectEqual(SpecState.superseded, rows[1].state);
    try std.testing.expectEqual(SpecState.not_executed, rows[2].state);
    try std.testing.expectEqualStrings("sm", rows[2].suite);
}