Skip to content
zit Artifact [17b2ae4aa5]
Login

Artifact [17b2ae4aa5]

Artifact 17b2ae4aa5c3f8d48c60371e25128b953a810fc3766537eeba66958a1a70ceef:


//! Artifact Format — draft-fossil-artifact-format-00.
//!
//! An artifact is an immutable byte sequence named by the lowercase-hex hash
//! of its content (Section 3). Structural artifacts are line-oriented "cards".
//! This module owns the canonical vocabulary for the whole suite: naming, the
//! card grammar, the structural types, and the two checksums (Z, R).
//!
//! Scope note: zit targets the version-control substrate — the check-in
//! (manifest), cluster, and tag/control artifact types. The collaboration
//! types (wiki, ticket, forum, attachment, technote) are carried opaquely.

const std = @import("std");

/// Card-type letters. Only the version-control cards are interpreted; others
/// are recognised syntactically and otherwise carried opaquely.
pub const CardType = enum(u8) {
    B = 'B', // baseline (delta manifest)
    C = 'C', // comment
    D = 'D', // date (mtime)
    F = 'F', // file
    M = 'M', // cluster member
    N = 'N', // mimetype
    P = 'P', // parent(s)
    Q = 'Q', // cherrypick
    R = 'R', // aggregate content checksum (MD5)
    T = 'T', // tag
    U = 'U', // user
    Z = 'Z', // artifact self-checksum (MD5), always last
};

/// Structural artifact types in scope for the version-control substrate.
pub const ArtifactType = enum {
    manifest, // a check-in
    cluster, // a set of artifact hashes (sync housekeeping)
    control, // a tag/branch mutation
    other, // recognised but carried opaquely (wiki/ticket/forum/...)
};

/// Hash algorithm that names an artifact (Section 3, Section 7 hash policy).
/// The canonical (writer) form is always lowercase hex.
pub const HashAlgo = enum { sha1, sha3_256 };

/// Compute the artifact name: the lowercase-hex hash of `content`.
/// Writes into `out` and returns the populated slice (40 hex for SHA-1,
/// 64 hex for SHA3-256).
pub fn artifactName(algo: HashAlgo, content: []const u8, out: *[64]u8) []u8 {
    switch (algo) {
        .sha1 => {
            var digest: [20]u8 = undefined;
            std.crypto.hash.Sha1.hash(content, &digest, .{});
            const hex = std.fmt.bytesToHex(digest, .lower);
            @memcpy(out[0..hex.len], &hex);
            return out[0..hex.len];
        },
        .sha3_256 => {
            var digest: [32]u8 = undefined;
            std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
            const hex = std.fmt.bytesToHex(digest, .lower);
            @memcpy(out[0..hex.len], &hex);
            return out[0..hex.len];
        },
    }
}

/// Length-to-algorithm dispatch (§3.2-m1). The two name lengths are disjoint,
/// so length alone fully determines the algorithm: 40 hex digits is SHA-1,
/// 64 is SHA3-256, any other length names nothing.
pub fn nameAlgo(len: usize) ?HashAlgo {
    return switch (len) {
        40 => .sha1,
        64 => .sha3_256,
        else => null,
    };
}

/// ABNF `lhexdig` (§3.5): a lowercase hex digit, 0-9 or a-f. Governs the
/// canonical (writer/parse) form only.
fn isHexLower(c: u8) bool {
    return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
}

/// ABNF `hexdig-ci` (§3.5): 0-9, A-F, or a-f. Used only on the prefix-lookup
/// path (§3.6), which canonicalizes case; the parse path never uses this.
fn isHexCi(c: u8) bool {
    return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F');
}

/// Canonical-form validation (§3.2, §3.3, §3.5 `artifact-name`). A token is
/// valid iff it is exactly 40 or 64 lowercase hex digits. Length is checked
/// before character class (§3.2-r1: length gate precedes char-class check).
/// Uppercase digits are rejected outright, never case-normalized (§3.3-m2).
pub fn validateName(token: []const u8) bool {
    if (nameAlgo(token.len) == null) return false;
    std.debug.assert(token.len == 40 or token.len == 64);
    var i: usize = 0;
    while (i < token.len) : (i += 1) {
        if (!isHexLower(token[i])) return false;
    }
    return true;
}

/// Verify `name` against `content` (§3.4). Dispatch on length picks the
/// digest to recompute; the compare is exact byte-for-byte lowercase hex.
/// Wrong length, non-hex characters, and a digest mismatch collapse into the
/// same `false` (§3.4-m1) — nothing here distinguishes them for the caller.
/// An uppercase name never verifies either, since the compare never
/// case-folds (§3.4-r1).
pub fn verify(name: []const u8, content: []const u8) bool {
    const algo = nameAlgo(name.len) orelse return false;
    var buf: [64]u8 = undefined;
    const computed = artifactName(algo, content, &buf);
    std.debug.assert(computed.len == name.len);
    return std.mem.eql(u8, name, computed);
}

/// Prefix-resolution outcome (§3.6, informative — not part of the wire
/// format). `one` carries the matching index into the caller's `names`.
pub const Resolution = union(enum) { none, one: usize, ambiguous };

/// Resolve a hex name or prefix against a candidate universe (§3.6).
/// `prefix` must be 4-64 hex characters of any case; it is lowercased before
/// matching (the lookup path canonicalizes case, unlike the parse path —
/// see `validateName`). One query walk covers both name lengths, so a
/// cross-algorithm collision (one 40-char match, one 64-char match) reports
/// `.ambiguous` exactly like a same-algorithm collision (§3.6-r1).
pub fn resolvePrefix(prefix: []const u8, names: []const []const u8) error{InvalidPrefix}!Resolution {
    if (prefix.len < 4 or prefix.len > 64) return error.InvalidPrefix;
    std.debug.assert(prefix.len >= 4 and prefix.len <= 64);

    var lowered: [64]u8 = undefined;
    var i: usize = 0;
    while (i < prefix.len) : (i += 1) {
        const c = prefix[i];
        if (!isHexCi(c)) return error.InvalidPrefix;
        lowered[i] = if (c >= 'A' and c <= 'F') c + ('a' - 'A') else c;
    }
    const query = lowered[0..prefix.len];

    var first: ?usize = null;
    var matches: usize = 0;
    var n: usize = 0;
    while (n < names.len) : (n += 1) {
        std.debug.assert(matches <= names.len);
        if (std.mem.startsWith(u8, names[n], query)) {
            matches += 1;
            if (first == null) first = n;
            if (matches > 1) return .ambiguous;
        }
    }
    if (first) |idx| {
        std.debug.assert(idx < names.len);
        return .{ .one = idx };
    }
    return .none;
}

/// The Z-card value: lowercase-hex MD5 of all stripped-content bytes that
/// precede the `Z ` line (Section 6, Z card).
pub fn selfChecksum(stripped_prefix: []const u8, out: *[32]u8) []u8 {
    var digest: [16]u8 = undefined;
    std.crypto.hash.Md5.hash(stripped_prefix, &digest, .{});
    const hex = std.fmt.bytesToHex(digest, .lower);
    @memcpy(out, &hex);
    return out;
}

// ---------------------------------------------------------------------------
// Manifest parsing (Sections 4, 5) — TODO
// ---------------------------------------------------------------------------

pub const Manifest = struct {
    // Populated by parse(): cards, type, name. Skeleton for now.
    kind: ArtifactType = .other,
};

pub const ParseError = error{ SyntaxError, NotImplemented };

/// Parse a structural artifact from its bytes. See Section 4 (card syntax),
/// Section 5 (types + per-type validity). Not yet implemented.
pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) ParseError!Manifest {
    _ = allocator;
    _ = bytes;
    return ParseError.NotImplemented;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

test "artifact §3.1: name is SHA3-256 of exact raw bytes" {
    var out: [64]u8 = undefined;
    // §A.3 / Table 8 (RFC line 2584): alpha.txt's content is "hello world\n",
    // named by the SHA3-256 given as its F-card hash.
    const name = artifactName(.sha3_256, "hello world\n", &out);
    try std.testing.expectEqualStrings(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        name,
    );
}

// The §A.1.1 SHA-1 worked-example manifest: 127 raw bytes (27+22+2+35+6+35
// per the §A.2 Z-card offset table), literal backslash-s escapes retained
// exactly as printed in the RFC. Its stated name is
// a28c83647dfa805f05f3204a7e146eb1f0d90505 (RFC line 2426).
const a1_1_manifest = @embedFile("rfc_vectors/artifact_a1_1_sha1_manifest.txt");
const a1_1_name = "a28c83647dfa805f05f3204a7e146eb1f0d90505";

test "artifact §3.2: 40-hex name is SHA-1 over raw bytes" {
    try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len);
    var out: [64]u8 = undefined;
    const name = artifactName(.sha1, a1_1_manifest, &out);
    try std.testing.expectEqualStrings(a1_1_name, name);
}

test "artifact §3.2: name length selects algorithm" {
    try std.testing.expectEqual(HashAlgo.sha1, nameAlgo(40).?);
    try std.testing.expectEqual(HashAlgo.sha3_256, nameAlgo(64).?);
}

test "artifact §3.2: name of any other length is rejected" {
    const bad_lens = [_]usize{ 0, 4, 39, 41, 63, 65, 128 };
    for (bad_lens) |len| {
        try std.testing.expect(nameAlgo(len) == null);
        // Fabricate an all-'a' token of that length: rejection here must be
        // on length alone, before any character-class check (§3.2-r1).
        var buf: [128]u8 = undefined;
        std.debug.assert(len <= buf.len);
        @memset(buf[0..len], 'a');
        try std.testing.expect(!validateName(buf[0..len]));
    }
}

test "artifact §3.3: computed name is lowercase hex" {
    var out: [64]u8 = undefined;
    const sha1_name = artifactName(.sha1, a1_1_manifest, &out);
    for (sha1_name) |c| try std.testing.expect(isHexLower(c));

    var out2: [64]u8 = undefined;
    const sha3_name = artifactName(.sha3_256, "hello world\n", &out2);
    for (sha3_name) |c| try std.testing.expect(isHexLower(c));
}

test "artifact §3.3: validation rejects uppercase, does not normalize" {
    // Same fixture as the rejection test above, restated to make the point
    // explicit: a normalize-then-accept parser would flip this to true.
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!validateName(&upper));
    // And the lowercase original still validates, proving the reject is
    // case-specific rather than a blanket failure.
    try std.testing.expect(validateName(a1_1_name));
}

test "artifact §3.4: verify dispatches on length" {
    try std.testing.expect(verify(a1_1_name, a1_1_manifest));
    try std.testing.expect(verify(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        "hello world\n",
    ));
}

test "artifact §3.4: uppercase name never verifies" {
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!verify(&upper, a1_1_manifest));
}

test "artifact §3.4: one undifferentiated failure signal" {
    // Wrong length.
    try std.testing.expect(!verify("deadbeef10", a1_1_manifest));
    // Non-hex character at a valid (40) length.
    var non_hex: [40]u8 = undefined;
    @memcpy(&non_hex, a1_1_name);
    non_hex[0] = 'g';
    try std.testing.expect(!verify(&non_hex, a1_1_manifest));
    // Valid-form 40-hex name, wrong digest (mismatched content).
    try std.testing.expect(!verify(a1_1_name, "hello world\n"));
}

test "artifact §3.5: ABNF canonical grammar accepts lhexdig only" {
    var boundary: [40]u8 = undefined;
    @memset(&boundary, '0');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, '9');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, 'a');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, 'f');
    try std.testing.expect(validateName(&boundary));

    var has_g: [40]u8 = undefined;
    @memset(&has_g, 'a');
    has_g[39] = 'g';
    try std.testing.expect(!validateName(&has_g));

    var has_upper: [40]u8 = undefined;
    @memset(&has_upper, 'a');
    has_upper[0] = 'A';
    try std.testing.expect(!validateName(&has_upper));

    var sha3_all_f: [64]u8 = undefined;
    @memset(&sha3_all_f, 'f');
    try std.testing.expect(validateName(&sha3_all_f));
}

test "artifact §3.6: prefix resolves and lowercases uppercase input" {
    const names = [_][]const u8{a1_1_name};
    switch (try resolvePrefix("a28c", &names)) {
        .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx),
        else => return error.TestUnexpectedResult,
    }
    switch (try resolvePrefix("A28C", &names)) {
        .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx),
        else => return error.TestUnexpectedResult,
    }
}

test "artifact §3.6: prefix length outside 4..64 is invalid" {
    const names = [_][]const u8{a1_1_name};
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix("a28", &names));
    var too_long: [65]u8 = undefined;
    @memset(&too_long, 'a');
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix(&too_long, &names));
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix("zzzz", &names));
}

test "artifact §3.6: ambiguous prefix, incl. cross-algorithm collision" {
    // Cross-algorithm: one SHA-1-named, one SHA3-named, sharing "a28c".
    const cross = [_][]const u8{
        a1_1_name,
        "a28c000000000000000000000000000000000000000000000000000000000000",
    };
    try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &cross));

    // Same-algorithm: two 40-char names sharing a prefix, reported the same.
    const same = [_][]const u8{
        a1_1_name,
        "a28c000000000000000000000000000000000000",
    };
    try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &same));
}

test "self-checksum = lowercase MD5 (empty input vector)" {
    var out: [32]u8 = undefined;
    const z = selfChecksum("", &out);
    try std.testing.expectEqualStrings("d41d8cd98f00b204e9800998ecf8427e", z);
}