Artifact 88f48886c33454528b36d13f2043cdd78d22103b881826718f391e77f3569db1:
- File
src/artifact_test.zig
— part of check-in
[32ef56aaf4]
at
2026-08-25 21:09:31
on branch rfc-vector-guard
— rfc vectors: keep them inert on disk
An unguarded vector parses as a real artifact, so Fossil crosslinks it into every clone -- two check-ins, a technote, twelve sub-manifest slices and a phantom pair that sync chases forever. Each vector now carries a leading # block; rfc_vectors.zig strips it at comptime so callers still see the exact RFC bytes.
Background and the cleanup that follows: see wiki page 'rfc-vector-ledger'. (user: dmestas, size: 306018) [annotate] [blame] [check-ins using] [more...]
const std = @import("std"); const rfc_vectors = @import("rfc_vectors.zig"); const af = @import("artifact.zig"); // Public-surface aliases so the moved tests read exactly as they did inline. const ArtifactType = af.ArtifactType; const BCard = af.BCard; const BaselineResolver = af.BaselineResolver; const Census = af.Census; const ClassifyError = af.ClassifyError; const FCard = af.FCard; const FCardAdmission = af.FCardAdmission; const FCardSortError = af.FCardSortError; const FileMode = af.FileMode; const HashAlgo = af.HashAlgo; const HashPolicy = af.HashPolicy; const IntraRunRule = af.IntraRunRule; const LookupResult = af.LookupResult; const Manifest = af.Manifest; const ManifestFiles = af.ManifestFiles; const MergeWalk = af.MergeWalk; const OccurrenceClass = af.OccurrenceClass; const OrderState = af.OrderState; const ParseError = af.ParseError; const QSign = af.QSign; const Resolution = af.Resolution; const SCard = af.SCard; const SingleOccurrenceSet = af.SingleOccurrenceSet; const SubManifestView = af.SubManifestView; const SubResolver = af.SubResolver; const TSign = af.TSign; const TrackedFile = af.TrackedFile; const TreeError = af.TreeError; const TreeWalk = af.TreeWalk; const WCard = af.WCard; const ZVerdict = af.ZVerdict; const TreeBuild = af.TreeBuild; const aggregateChecksum = af.aggregateChecksum; const buildTree = af.buildTree; const encodeCanonical = af.encodeCanonical; const zbits = af.zbits; const alternativeHash = af.alternativeHash; const artifactName = af.artifactName; const classify = af.classify; const classifyTimestamp = af.classifyTimestamp; const coerceAssignment = af.coerceAssignment; const compareBytes = af.compareBytes; const compareOptional = af.compareOptional; const compareTag = af.compareTag; const create = af.create; const decodeEscapes = af.decodeEscapes; const dedupNameAlgo = af.dedupNameAlgo; const defaultPolicy = af.defaultPolicy; const determineType = af.determineType; const encodeEscapes = af.encodeEscapes; const enumerate = af.enumerate; const frame = af.frame; const intraRunRule = af.intraRunRule; const isCardLetter = af.isCardLetter; const isDelta = af.isDelta; const isReservedFilename = af.isReservedFilename; const isSimplePathname = af.isSimplePathname; const isTokenByte = af.isTokenByte; const isWikiName = af.isWikiName; const isWriterTimestamp = af.isWriterTimestamp; const lookup = af.lookup; const lookupTree = af.lookupTree; const max_tree_depth = af.max_tree_depth; const nameAlgo = af.nameAlgo; const occurrenceClass = af.occurrenceClass; const parse = af.parse; const parseForImport = af.parseForImport; const ImportedManifest = af.ImportedManifest; const parseACard = af.parseACard; const parseBCard = af.parseBCard; const parseCCard = af.parseCCard; const parseDCard = af.parseDCard; const parseECard = af.parseECard; const parseFCard = af.parseFCard; const parseGCard = af.parseGCard; const parseHCard = af.parseHCard; const parseICard = af.parseICard; const parseJCard = af.parseJCard; const parseKCard = af.parseKCard; const parseLCard = af.parseLCard; const parseMCard = af.parseMCard; const parseNCard = af.parseNCard; const parsePCard = af.parsePCard; const parseQCard = af.parseQCard; const parseRCard = af.parseRCard; const parseSCard = af.parseSCard; const parseTCard = af.parseTCard; const parseUCard = af.parseUCard; const parseWCard = af.parseWCard; const parseZCard = af.parseZCard; const policyFromString = af.policyFromString; const policyName = af.policyName; const primaryHash = af.primaryHash; const ratchet = af.ratchet; const resolvePrefix = af.resolvePrefix; const selfChecksum = af.selfChecksum; const stripSignatureWrapper = af.stripSignatureWrapper; const tokenizeCardLine = af.tokenizeCardLine; const validateHashToken = af.validateHashToken; const validateName = af.validateName; const verify = af.verify; const verifyZCard = af.verifyZCard; 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, ); } const a1_1_manifest = rfc_vectors.artifact_a1_1_sha1_manifest; 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: 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)); } const sha3_name_64 = "2cee2f88646e224275d9445d523e4a8021f8e6cffa2b3978f8ea324cb0210261"; test "artifact §4.7.1: artifact-hash accepts 40 and 64 hex" { try std.testing.expect(validateHashToken(.artifact_hash, a1_1_name)); try std.testing.expect(validateHashToken(.artifact_hash, sha3_name_64)); } test "artifact §4.7.1: artifact-hash is case-insensitive per its explicit MUST" { // §4.7.1: "MUST be exactly 40 or 64 case-insensitive hexadecimal // characters" — a harder requirement than artifact-name's MAY (§3.3), // so upper- and mixed-case tokens are accepted, unlike validateName. var upper: [40]u8 = undefined; for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); try std.testing.expect(validateHashToken(.artifact_hash, &upper)); var mixed: [64]u8 = undefined; for (sha3_name_64, 0..) |c, i| { mixed[i] = if (i % 2 == 0) std.ascii.toUpper(c) else c; } try std.testing.expect(validateHashToken(.artifact_hash, &mixed)); } test "artifact §4.7.1: artifact-hash rejects wrong length and non-hex" { // §4.7.1-p1 length gate; §3.2-r1 length precedes char-class check. const bad_lens = [_]usize{ 39, 41, 32, 63, 65 }; var buf: [65]u8 = undefined; @memset(&buf, 'a'); for (bad_lens) |len| { std.debug.assert(len <= buf.len); try std.testing.expect(!validateHashToken(.artifact_hash, buf[0..len])); } var non_hex: [40]u8 = undefined; @memcpy(&non_hex, a1_1_name); non_hex[0] = 'g'; try std.testing.expect(!validateHashToken(.artifact_hash, &non_hex)); } test "artifact §4.7.1: K ticket id is 40, never 64" { // §4.7.1-r1: pinned to SHA-1 length regardless of hash policy. try std.testing.expect(validateHashToken(.ticket_id, a1_1_name)); try std.testing.expect(!validateHashToken(.ticket_id, sha3_name_64)); // Negative space beyond the two named lengths, and a non-hex char. try std.testing.expect(!validateHashToken(.ticket_id, a1_1_name[0..39])); var non_hex: [40]u8 = undefined; @memcpy(&non_hex, a1_1_name); non_hex[0] = 'g'; try std.testing.expect(!validateHashToken(.ticket_id, &non_hex)); } test "artifact §4.7.1: R/Z checksum tokens are exactly 32 hex" { // §4.7.1-r2, §6.1 md5-token = 32hexdig-ci. const md5 = "d41d8cd98f00b204e9800998ecf8427e"; try std.testing.expect(validateHashToken(.checksum, md5)); try std.testing.expect(!validateHashToken(.checksum, a1_1_name)); try std.testing.expect(!validateHashToken(.checksum, md5[0..31])); var too_long: [33]u8 = undefined; @memcpy(too_long[0..32], md5); too_long[32] = 'a'; try std.testing.expect(!validateHashToken(.checksum, &too_long)); var non_hex: [32]u8 = undefined; @memcpy(&non_hex, md5); non_hex[0] = 'g'; try std.testing.expect(!validateHashToken(.checksum, &non_hex)); } test "artifact §4.7.1: checksum is case-insensitive; ticket id is lowercase-only" { // §6.1 "parse-accept grammar is case-insensitive" for R/Z; §4.7.1's K // exception takes the artifact-name-style MAY and stays lowercase-only, // unlike the artifact-hash arm's case-insensitive MUST (covered above). try std.testing.expect(validateHashToken(.checksum, "D41D8CD98F00B204E9800998ECF8427E")); var upper: [40]u8 = undefined; for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); try std.testing.expect(!validateHashToken(.ticket_id, &upper)); } test "artifact §4.7.1: the map never escape-decodes a raw token" { // §4.7.1-m2; §4.3.1 defines the A-target decode-then-validate contract // that lives entirely in the future A-card parser, not here. A decoding // validator would strip the leading "\a" -> "a" and see 40 valid hex // bytes; the raw validator must see 41 bytes including a non-hex '\'. var wire: [41]u8 = undefined; wire[0] = '\\'; @memcpy(wire[1..41], a1_1_name); try std.testing.expect(!validateHashToken(.artifact_hash, &wire)); // Interior backslash inside an otherwise 40-byte token. var interior: [40]u8 = undefined; @memcpy(&interior, a1_1_name); interior[10] = '\\'; try std.testing.expect(!validateHashToken(.artifact_hash, &interior)); } test "artifact §6.2: self-checksum of empty preceding content is lowercase MD5 of the empty input" { var out: [32]u8 = undefined; const z = selfChecksum("", &out); try std.testing.expectEqualStrings("d41d8cd98f00b204e9800998ecf8427e", z); } test "artifact §4.1.1: stripping only when first 34 bytes match armor" { // Under 34 bytes: too short to match, returned unchanged. const short = "-----BEGIN PGP SIGNED MESSAGE---"; // 32 bytes, two dashes short try std.testing.expectEqualStrings(short, stripSignatureWrapper(short)); // Armor text present, but not at offset 0: unchanged. const offset5 = "12345-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n"; try std.testing.expectEqualStrings(offset5, stripSignatureWrapper(offset5)); // 34 bytes, none of them the armor literal: unchanged. const not_armor = "x" ** 34 ++ "\n\nbody\n"; try std.testing.expectEqualStrings(not_armor, stripSignatureWrapper(not_armor)); // Exact open armor at offset 0: stripped. const wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n"; try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(wrapped)); } test "artifact §4.1.1: body begins after first blank line (LF LF or LF CR LF)" { const wrapped_lf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nbody line\n"; const wrapped_crlf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\r\nbody line\n"; try std.testing.expectEqualStrings("body line\n", stripSignatureWrapper(wrapped_lf)); try std.testing.expectEqualStrings("body line\n", stripSignatureWrapper(wrapped_crlf)); } test "artifact §4.1.1: no blank line leaves content wholly unstripped" { const wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\nhello\n"; try std.testing.expectEqualStrings(wrapped, stripSignatureWrapper(wrapped)); } test "artifact §4.1.1: trailing signature armor truncates, retaining the LF" { const pgp_wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++ "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----\n"; try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(pgp_wrapped)); const ssh_wrapped = "-----BEGIN SSH SIGNED MESSAGE-----\n\nbody\n" ++ "-----BEGIN SSH SIGNATURE-----\nabc\n-----END SSH SIGNATURE-----\n"; try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(ssh_wrapped)); // No trailing armor at all: runs to end of input. const no_close = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n"; try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(no_close)); } test "artifact §4.1.1: trailing armor one dash short still matches (29 of 30)" { // 4 trailing dashes (one short of the closing run) + arbitrary byte: matches. const one_short = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++ "-----BEGIN PGP SIGNATURE----X\ntrailer\n"; try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(one_short)); // 3 trailing dashes (two short): the 29-byte compare fails, runs to EOF. const two_short = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++ "-----BEGIN PGP SIGNATURE---\ntrailer\n"; const expected_two_short = "body\n-----BEGIN PGP SIGNATURE---\ntrailer\n"; try std.testing.expectEqualStrings(expected_two_short, stripSignatureWrapper(two_short)); } test "artifact §4.1.1: PGP and SSH wrappers are removed before parsing" { const pgp_wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++ a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n"; try std.testing.expectEqualStrings(a1_1_manifest, stripSignatureWrapper(pgp_wrapped)); const ssh_wrapped = "-----BEGIN SSH SIGNED MESSAGE-----\n\n" ++ a1_1_manifest ++ "-----BEGIN SSH SIGNATURE-----\nabc123\n-----END SSH SIGNATURE-----\n"; try std.testing.expectEqualStrings(a1_1_manifest, stripSignatureWrapper(ssh_wrapped)); } test "artifact §4.1: raw input must be non-empty and end in LF" { try std.testing.expect(frame("") == null); try std.testing.expect(frame("C comment") == null); // no trailing LF try std.testing.expect(frame("C comment\n") != null); } test "artifact §4.1: stripped content under 10 bytes is rejected" { try std.testing.expect(frame("C 123456\n") == null); // 9 bytes try std.testing.expect(frame("C 1234567\n") != null); // 10 bytes } test "artifact §4.1: first stripped byte must be uppercase A-Z" { const table = [_]struct { byte0: u8, ok: bool }{ .{ .byte0 = '@', .ok = false }, // 0x40, just below 'A' .{ .byte0 = 'A', .ok = true }, .{ .byte0 = 'Z', .ok = true }, .{ .byte0 = '[', .ok = false }, // 0x5B, just above 'Z' .{ .byte0 = 'c', .ok = false }, // lowercase }; for (table) |row| { var buf = [_]u8{ row.byte0, ' ', '1', '2', '3', '4', '5', '6', '7', '\n' }; std.debug.assert(buf.len == 10); try std.testing.expectEqual(row.ok, frame(&buf) != null); } } test "artifact §4.1: second stripped byte must be a single space" { const table = [_]struct { byte1: u8, ok: bool }{ .{ .byte1 = ' ', .ok = true }, .{ .byte1 = '\t', .ok = false }, .{ .byte1 = 'X', .ok = false }, .{ .byte1 = '\n', .ok = false }, // argument-less first card }; for (table) |row| { var buf = [_]u8{ 'C', row.byte1, '1', '2', '3', '4', '5', '6', '7', '\n' }; std.debug.assert(buf.len == 10); try std.testing.expectEqual(row.ok, frame(&buf) != null); } } test "artifact §4.1/§6.2: present but mismatched Z card is rejected" { // Z card occupies the final 35 bytes of the 127-byte manifest: Z@92, // SP@93, digest 94..125, LF@126 (§A.2 offset table). var flipped: [127]u8 = undefined; @memcpy(&flipped, a1_1_manifest); flipped[94] = 'd'; // digest[0] was 'c'; still hex, still a mismatch try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&flipped)); try std.testing.expect(frame(&flipped) == null); var uppercased: [127]u8 = undefined; @memcpy(&uppercased, a1_1_manifest); uppercased[94] = 'C'; // numerically same digit, wrong case try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&uppercased)); try std.testing.expect(frame(&uppercased) == null); } test "artifact §4.1/§6.2: absent Z card passes the framing gate" { try std.testing.expectEqual(ZVerdict.absent, verifyZCard("C comment\n")); try std.testing.expect(frame("C comment\n") != null); try std.testing.expectEqual(ZVerdict.match, verifyZCard(a1_1_manifest)); try std.testing.expect(frame(a1_1_manifest) != null); } test "artifact §4.1: gates apply in order (raw-LF before strip)" { // Outer wrapper omits its final LF: gate 1 must fire on the raw bytes, // before stripping ever runs — the stripped inner body (a1_1_manifest) // always ends in LF regardless, so this only fails if gates run in order. const wrapped_no_lf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++ a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----"; try std.testing.expect(frame(wrapped_no_lf) == null); const wrapped_with_lf = wrapped_no_lf ++ "\n"; try std.testing.expect(frame(wrapped_with_lf) != null); } test "artifact §4.2.2: exactly the 22 defined letters are accepted" { // draft-fossil-submanifests-00 §3.1 revises §4.2.2 to read: "Exactly 22 // card letters are defined: A, B, C, D, E, F, G, H, I, J, K, L, M, N, P, // Q, R, S, T, U, W, Z." Still a closed set, still no forward-compat path. const undefined_in_range = "OVXY"; var accepted: usize = 0; var c: u8 = 'A'; while (true) : (c += 1) { const is_undefined = std.mem.indexOfScalar(u8, undefined_in_range, c) != null; try std.testing.expectEqual(!is_undefined, isCardLetter(c)); if (isCardLetter(c)) accepted += 1; if (c == 'Z') break; } try std.testing.expectEqual(@as(usize, 22), accepted); } test "artifact §4.2.2: card letter outside A-Z is rejected" { // Line 624: "A card letter outside A-Z MUST be rejected." const table = [_]u8{ 0x40, '[', 'c', '0' }; // '@' just below 'A', '[' just above 'Z' for (table) |b| { try std.testing.expect(!isCardLetter(b)); var buf = [_]u8{ b, '\n' }; try std.testing.expect(tokenizeCardLine(&buf) == null); } } test "artifact §4.2.2: in-range undefined letters O V X Y are rejected" { // draft-fossil-submanifests-00 §3.1: "an in-range but undefined letter // (O, V, X, Y) MUST be rejected." S left this set when it became the // 22nd card letter. const table = [_]u8{ 'O', 'V', 'X', 'Y' }; for (table) |b| { try std.testing.expect(!isCardLetter(b)); var buf = [_]u8{ b, '\n' }; try std.testing.expect(tokenizeCardLine(&buf) == null); } } test "artifact §4.2: tchar admits every octet except SP and LF" { // Lines 590-591: "tchar = %x00-09 / %x0B-1F / %x21-FF ; any octet except // SP (0x20) and LF (0x0A)". const admitted = [_]u8{ 0x00, 0x09, 0x0B, 0x1F, 0x21, 0xFF }; for (admitted) |b| try std.testing.expect(isTokenByte(b)); const excluded = [_]u8{ 0x0A, 0x20 }; for (excluded) |b| try std.testing.expect(!isTokenByte(b)); } test "artifact §4.2: card tokenizes to letter and SP-split tokens" { // Grammar lines 586-589: card-args = token *( SP token ). const line = "F path abc123 w\n"; const card = tokenizeCardLine(line).?; try std.testing.expectEqual(@as(u8, 'F'), card.letter); try std.testing.expectEqualStrings("path abc123 w", card.args.?); var it = card.tokens(); try std.testing.expectEqualStrings("path", it.next().?); try std.testing.expectEqualStrings("abc123", it.next().?); try std.testing.expectEqualStrings("w", it.next().?); try std.testing.expect(it.next() == null); } test "artifact §4.2-m1: byte after card letter must be SP or LF, tab rejected" { // Lines 593-595: "MUST be exactly one space (0x20 ...) or LF ... Any // other second byte — including tab (0x09) — makes the card invalid." { var buf = "C x\n".*; const card = tokenizeCardLine(&buf).?; try std.testing.expectEqualStrings("x", card.args.?); } { var buf = "C\n".*; const card = tokenizeCardLine(&buf).?; try std.testing.expectEqual(@as(?[]const u8, null), card.args); var it = card.tokens(); try std.testing.expect(it.next() == null); } { var buf = "C\tx\n".*; try std.testing.expect(tokenizeCardLine(&buf) == null); } { var buf = "CX\n".*; try std.testing.expect(tokenizeCardLine(&buf) == null); } } test "artifact §4.2-m2: tokens split on space only; tab retained in token" { // Lines 596-598: "split on space and LF only; tab is not a delimiter and // is retained ... a token can never contain an embedded space." The // byte after the letter must still be SP (§4.2-m1); the tab lives // inside the token that follows, not in the letter/SP boundary. var buf = "T \tvalue\n".*; const card = tokenizeCardLine(&buf).?; var it = card.tokens(); const only = it.next().?; try std.testing.expectEqualStrings("\tvalue", only); try std.testing.expect(std.mem.indexOfScalar(u8, only, ' ') == null); try std.testing.expect(it.next() == null); var buf2 = "T a b c\n".*; const card2 = tokenizeCardLine(&buf2).?; var it2 = card2.tokens(); var n: usize = 0; while (it2.next()) |tok| : (n += 1) { try std.testing.expect(std.mem.indexOfScalar(u8, tok, ' ') == null); } try std.testing.expectEqual(@as(usize, 3), n); } test "artifact §4.2-m3: CR is an ordinary token byte, not a delimiter" { // Lines 598-600: "There is no CR (0x0D) handling in the card and token // path — a CR is an ordinary token byte." var buf = "C a\rb\n".*; const card = tokenizeCardLine(&buf).?; var it = card.tokens(); try std.testing.expectEqualStrings("a\rb", it.next().?); try std.testing.expect(it.next() == null); } test "artifact §4.2: bare U vs empty-token U distinguished (RFC line 1418)" { // §4.7.17: a bare U line (0 tokens) differs from a U followed by an // empty token (1 empty token) — both collapse to "anonymous" at the // per-card layer, but the tokenizer must still tell them apart. var bare = "U\n".*; const bare_card = tokenizeCardLine(&bare).?; try std.testing.expectEqual(@as(?[]const u8, null), bare_card.args); var bare_it = bare_card.tokens(); try std.testing.expect(bare_it.next() == null); var empty = "U \n".*; const empty_card = tokenizeCardLine(&empty).?; try std.testing.expectEqualStrings("", empty_card.args.?); var empty_it = empty_card.tokens(); try std.testing.expectEqualStrings("", empty_it.next().?); try std.testing.expect(empty_it.next() == null); } test "artifact §4.3-m1: the eight escape pairs decode to their octets" { // Table 1 (lines 635-653): each row's "Decoded octet" column. const table = [_]struct { input: []const u8, expected: u8 }{ .{ .input = "\\n", .expected = 0x0A }, .{ .input = "\\s", .expected = 0x20 }, .{ .input = "\\t", .expected = 0x09 }, .{ .input = "\\r", .expected = 0x0D }, .{ .input = "\\v", .expected = 0x0B }, .{ .input = "\\f", .expected = 0x0C }, .{ .input = "\\0", .expected = 0x00 }, .{ .input = "\\\\", .expected = 0x5C }, }; for (table) |row| { var out: [2]u8 = undefined; const decoded = decodeEscapes(row.input, &out); try std.testing.expectEqual(@as(usize, 1), decoded.len); try std.testing.expectEqual(row.expected, decoded[0]); } } test "artifact §4.3-m2: unrecognized escape drops the backslash, keeps the successor" { // Lines 657-660: "the decoder discards the backslash and emits the // following character unchanged ... decodes to the single byte C." const table = [_]struct { input: []const u8, expected: u8 }{ .{ .input = "\\x", .expected = 'x' }, .{ .input = "\\1", .expected = '1' }, // only \0 is in Table 1 .{ .input = "\\!", .expected = '!' }, }; for (table) |row| { var out: [2]u8 = undefined; const decoded = decodeEscapes(row.input, &out); try std.testing.expectEqual(@as(usize, 1), decoded.len); try std.testing.expectEqual(row.expected, decoded[0]); } } test "artifact §4.3-amb1: a lone trailing backslash is emitted literally" { // ambiguities.md RESOLVED reading #1 (pass-through, operator 2026-07-09): // a backslash with no successor forms no pair, emitted as its own byte. var out1: [1]u8 = undefined; const decoded1 = decodeEscapes("\\", &out1); try std.testing.expectEqualStrings("\\", decoded1); var out2: [2]u8 = undefined; const decoded2 = decodeEscapes("a\\", &out2); try std.testing.expectEqualStrings("a\\", decoded2); } test "artifact §4.3-w1: a pure-hexadecimal wire token decodes to itself" { // Line 698: "A pure-hexadecimal wire token decodes to itself" — no // backslash present, every byte is ordinary. const short = "deadbeef"; var out_short: [short.len]u8 = undefined; try std.testing.expectEqualStrings(short, decodeEscapes(short, &out_short)); const long = "a28c83647dfa805f05f3204a7e146eb1f0d9050500000000000000000000000"; var out_long: [long.len]u8 = undefined; try std.testing.expectEqualStrings(long, decodeEscapes(long, &out_long)); } test "artifact §4.3: escapes decode left-to-right without reprocessing output" { // §4.3 escapes are "decoded in place" over the wire token (lines // 632-633): the decoded backslash from \\ -> 0x5C is emitted, not // re-read, so a trailing 'n' after it stays literal (not LF). const input = "\\\\n"; // backslash, backslash, n var out: [3]u8 = undefined; const decoded = decodeEscapes(input, &out); try std.testing.expectEqual(@as(usize, 2), decoded.len); try std.testing.expectEqual(@as(u8, 0x5C), decoded[0]); try std.testing.expectEqual(@as(u8, 'n'), decoded[1]); var out_empty: [0]u8 = undefined; try std.testing.expectEqualStrings("", decodeEscapes("", &out_empty)); } test "artifact §4.3: encodeEscapes emits the Table 1 escape for each named octet" { const Row = struct { octet: u8, succ: u8 }; const rows = [_]Row{ .{ .octet = 0x0A, .succ = 'n' }, .{ .octet = 0x20, .succ = 's' }, .{ .octet = 0x09, .succ = 't' }, .{ .octet = 0x0D, .succ = 'r' }, .{ .octet = 0x0B, .succ = 'v' }, .{ .octet = 0x0C, .succ = 'f' }, .{ .octet = 0x00, .succ = '0' }, .{ .octet = 0x5C, .succ = '\\' }, }; for (rows) |row| { var out: [2]u8 = undefined; const encoded = encodeEscapes(&[_]u8{row.octet}, &out); try std.testing.expectEqual(@as(usize, 2), encoded.len); try std.testing.expectEqual(@as(u8, '\\'), encoded[0]); try std.testing.expectEqual(row.succ, encoded[1]); } } test "artifact §4.3/§A.1.1: the worked-example comment round-trips" { var buf: [64]u8 = undefined; const encoded = encodeEscapes("initial empty baseline", &buf); try std.testing.expectEqualStrings("initial\\sempty\\sbaseline", encoded); } test "artifact §4.3: encodeEscapes then decodeEscapes round-trips" { var prng = std.Random.DefaultPrng.init(0xA47F_0100); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var src: [64]u8 = undefined; const len = rand.uintLessThan(usize, src.len + 1); for (src[0..len]) |*b| b.* = rand.int(u8); const token = src[0..len]; var enc_buf: [128]u8 = undefined; const encoded = encodeEscapes(token, &enc_buf); try std.testing.expect(encoded.len <= 2 * token.len); try std.testing.expect(encoded.len >= token.len); var dec_buf: [128]u8 = undefined; const decoded = decodeEscapes(encoded, dec_buf[0..encoded.len]); try std.testing.expectEqualSlices(u8, token, decoded); } } test "artifact §4.4-p1: cards admit in non-decreasing type order" { // Lines 705-707: "MUST appear in non-decreasing lexicographic card-type // order: each card's type letter MUST be greater than or equal to the // previous card's type letter." var s = OrderState{}; const in_order = "CCDFTUZ"; for (in_order) |letter| try std.testing.expect(s.admit(letter)); var below = OrderState{}; try std.testing.expect(below.admit('F')); try std.testing.expect(!below.admit('D')); // D (0x44) < F (0x46) } test "artifact §4.4-p2: consecutive equal-letter cards are permitted" { // Lines 708-711: consecutive same-letter cards permitted; the ordering // is non-decreasing, not strictly increasing. var s1 = OrderState{}; try std.testing.expect(s1.admit('F')); try std.testing.expect(s1.admit('F')); var s2 = OrderState{}; try std.testing.expect(s2.admit('P')); try std.testing.expect(s2.admit('P')); } test "artifact §4.4-r1: a letter sorting below the previous is rejected" { // Lines 708-709: "The rejection condition fires only when a card's // letter sorts below the previous card's letter" — exactly that // condition, nothing else. const table = [_]struct { prev: u8, cur: u8, ok: bool }{ .{ .prev = 'D', .cur = 'C', .ok = false }, .{ .prev = 'P', .cur = 'C', .ok = false }, .{ .prev = 'Z', .cur = 'U', .ok = false }, .{ .prev = 'C', .cur = 'C', .ok = true }, .{ .prev = 'C', .cur = 'D', .ok = true }, }; for (table) |row| { var s = OrderState{}; try std.testing.expect(s.admit(row.prev)); try std.testing.expectEqual(row.ok, s.admit(row.cur)); } } test "artifact §4.4-m1: N after P admitted once a valid E precedes" { // Lines 714-717: "an N card MAY appear immediately after a P card when // the artifact has already presented a valid E card. A parser MUST // accept that sequence, and any other out-of-order card MUST be // rejected." var s = OrderState{}; for ("CDE") |letter| try std.testing.expect(s.admit(letter)); try std.testing.expect(s.admit('P')); try std.testing.expect(s.admit('N')); // N (0x4E) < P (0x50): exception fires // Same driver still rejects a non-exception out-of-order step. try std.testing.expect(!s.admit('C')); // C (0x43) < N (0x4E), not the exception } test "artifact §4.4-r2: the N-after-P exception requires a prior E card" { // Lines 714-716 precondition: "has already presented a valid E card." var no_e = OrderState{}; try std.testing.expect(no_e.admit('P')); try std.testing.expect(!no_e.admit('N')); // N < P, but e_seen is false: inert var with_e = OrderState{}; try std.testing.expect(with_e.admit('E')); try std.testing.expect(with_e.admit('P')); try std.testing.expect(with_e.admit('N')); // exception fires } test "artifact §4.4-r3: out-of-order cards other than N-after-P are rejected" { // Lines 716-717: "any other out-of-order card MUST be rejected." // Appendix C (lines 2952-2955): "other out-of-order cards remain // rejected." Only N-immediately-after-P is tolerated. { var s = OrderState{}; try std.testing.expect(s.admit('E')); try std.testing.expect(s.admit('P')); try std.testing.expect(!s.admit('M')); // M (0x4D) < P, not N } { var s = OrderState{}; try std.testing.expect(s.admit('E')); try std.testing.expect(s.admit('Q')); try std.testing.expect(!s.admit('N')); // prev is Q, not P: inert } { var s = OrderState{}; for ("EPN") |letter| try std.testing.expect(s.admit(letter)); try std.testing.expect(!s.admit('M')); // M < N, below-N step still rejects } } test "artifact §4.4-r4: no letter follows Z; the exception does not involve Z" { // Lines 739-741: "Z is the lexicographically last defined letter, the // ordering rule admits no letter after it (the N-after-P exception // does not involve Z)." var s1 = OrderState{}; try std.testing.expect(s1.admit('Z')); try std.testing.expect(!s1.admit('U')); var s2 = OrderState{}; try std.testing.expect(s2.admit('Z')); try std.testing.expect(!s2.admit('C')); var s3 = OrderState{}; try std.testing.expect(s3.admit('E')); try std.testing.expect(s3.admit('Z')); try std.testing.expect(!s3.admit('N')); // prev is Z, not P: exception inert } test "artifact §4.4-w1: the technote sequence C D E P N T U W Z is admitted" { // §A.1 worked example (lines 2471-2489) card letters in order; lines // 2498-2501: "the N card (MIME type) appears after the P card even // though N sorts before P in ASCII order. This is accepted because the // E card has already been parsed when N is encountered." var s = OrderState{}; const sequence = "CDEPNTUWZ"; for (sequence) |letter| try std.testing.expect(s.admit(letter)); } test "artifact §4.5.1: card letters partition into single / repeatable / neither" { // Lines 753-766, as amended by draft-fossil-submanifests-00 §3.4 ("S // joins the repeatable occurrence class ... which becomes: F, J, M, T, // P, Q, S"): 14 single-occurrence, 7 repeatable, 1 (Z) neither — 22 // total, matching the closed alphabet. var single: usize = 0; var repeatable: usize = 0; var neither: usize = 0; var c: u8 = 'A'; while (true) : (c += 1) { if (isCardLetter(c)) { switch (occurrenceClass(c)) { .single => single += 1, .repeatable => repeatable += 1, .neither => neither += 1, } } if (c == 'Z') break; } try std.testing.expectEqual(@as(usize, 14), single); try std.testing.expectEqual(@as(usize, 7), repeatable); try std.testing.expectEqual(@as(usize, 1), neither); } test "artifact §4.5.1-r1: a single-occurrence duplicate is rejected" { // Line 755-756: "Single-occurrence (a duplicate MUST be rejected): A, B, // C, D, E, G, H, I, K, L, N, R, U, W." Rejection: 2nd occurrence. const single_letters = "ABCDEGHIKLNRUW"; try std.testing.expectEqual(@as(usize, 14), single_letters.len); for (single_letters) |letter| { var set = SingleOccurrenceSet{}; try std.testing.expect(set.admit(letter)); try std.testing.expect(!set.admit(letter)); } } test "artifact §4.5.1-m1: repeatable card types are not single-occurrence" { // Lines 758-762: "Repeatable (each occurrence appends ...): F, J, M, T, // P, Q. It is not the case that any duplicate card type is a syntax // error." A duplicate must be admitted, not rejected. const repeatable_letters = "FJMTPQ"; try std.testing.expectEqual(@as(usize, 6), repeatable_letters.len); for (repeatable_letters) |letter| { try std.testing.expectEqual(OccurrenceClass.repeatable, occurrenceClass(letter)); var set = SingleOccurrenceSet{}; try std.testing.expect(set.admit(letter)); try std.testing.expect(set.admit(letter)); } } test "artifact §4.5.1-m2: the Z card belongs to neither class and has no duplicate guard" { // Lines 764-766: "The Z card ... belongs to neither class. It has no // duplicate guard, and the ordering rule permits equal consecutive // letters." try std.testing.expectEqual(OccurrenceClass.neither, occurrenceClass('Z')); var set = SingleOccurrenceSet{}; try std.testing.expect(set.admit('Z')); try std.testing.expect(set.admit('Z')); try std.testing.expectEqual(@as(?IntraRunRule, null), intraRunRule('Z')); } test "artifact §4.5.2-r1: F cards must strictly ascend by decoded filename" { // Table 2 line 792: "Strictly ascending by decoded filename (equal or // descending rejected)." try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('F').?); try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "b")); // Rejection: equal. try std.testing.expectEqual(std.math.Order.eq, compareBytes("a", "a")); // Rejection: descending. try std.testing.expectEqual(std.math.Order.gt, compareBytes("b", "a")); } test "artifact §4.5.2-m1: the F sort check runs on the post-decode filename" { // Lines 813-817: "runs on the post-decode filename, not on the raw // wire-encoded token (decoding happens before the sort check)." Wire // "a\sb" decodes (§4.3 Table 1, \s -> SP 0x20) to "a b"; wire "a!b" has // no escape and stays "a!b". Decoded 2nd byte 0x20 < 0x21 ascends. var buf1: [8]u8 = undefined; const decoded1 = decodeEscapes("a\\sb", &buf1); try std.testing.expectEqualStrings("a b", decoded1); const raw2 = "a!b"; try std.testing.expectEqual(std.math.Order.lt, compareBytes(decoded1, raw2)); // On the RAW wire token (undecoded), "a\sb" is 0x61 0x5C 0x73 0x62: 2nd // byte 0x5C > 0x21, so a raw-token compare would report .gt (reject) — // the opposite of the post-decode result, proving decode precedes sort. const raw1 = "a\\sb"; try std.testing.expectEqual(std.math.Order.gt, compareBytes(raw1, raw2)); } test "artifact §4.5.2-r2: J cards must strictly ascend by field name" { // Table 2 line 795: "Strictly ascending by field name (equal or // descending rejected)." try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('J').?); try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "b")); try std.testing.expectEqual(std.math.Order.eq, compareBytes("a", "a")); try std.testing.expectEqual(std.math.Order.gt, compareBytes("b", "a")); } test "artifact §4.5.2-r3: M cards must strictly ascend by hash" { // Table 2 line 798: "Strictly ascending by hash (equal or descending // rejected)." try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('M').?); try std.testing.expectEqual(std.math.Order.lt, compareBytes("aa", "ab")); try std.testing.expectEqual(std.math.Order.eq, compareBytes("aa", "aa")); try std.testing.expectEqual(std.math.Order.gt, compareBytes("ab", "aa")); } test "artifact §4.5.2-r4: T cards ascend two-level by tag name then target hash" { // Table 2 lines 801-802: "Two-level ascending by (tag name, then target // hash); equal names require strictly increasing hash." try std.testing.expectEqual(IntraRunRule.tag_two_level, intraRunRule('T').?); // Names ascend: hash irrelevant. try std.testing.expectEqual(std.math.Order.lt, compareTag("a", "zz", "b", "aa")); // Equal name, ascending hash. try std.testing.expectEqual(std.math.Order.lt, compareTag("a", "aa", "a", "ab")); // Rejection: equal name, equal hash. try std.testing.expectEqual(std.math.Order.eq, compareTag("a", "aa", "a", "aa")); // Rejection: equal name, descending hash. try std.testing.expectEqual(std.math.Order.gt, compareTag("a", "ab", "a", "aa")); // Rejection: descending name (hash ascending doesn't rescue it). try std.testing.expectEqual(std.math.Order.gt, compareTag("b", "aa", "a", "zz")); } test "artifact §4.5.2-m2: P cards are neither sorted nor deduplicated" { // Table 2 lines 804-805: "Not sorted or deduplicated; order is semantic // (primary parent first)." try std.testing.expectEqual(IntraRunRule.unordered, intraRunRule('P').?); } test "artifact §4.5.2-m3: Q cards are unordered-repeatable with no duplicate guard" { // Table 2 lines 807-808: "No duplicate guard or ordering check; // unordered-repeatable." try std.testing.expectEqual(IntraRunRule.unordered, intraRunRule('Q').?); var set = SingleOccurrenceSet{}; try std.testing.expect(set.admit('Q')); try std.testing.expect(set.admit('Q')); } test "artifact §4.5.3-p1: the comparator is unsigned, case-sensitive, and normalization-free" { // Lines 827-829: "octets are compared as unsigned values, // locale-independent, with no Unicode normalization and no case // folding." try std.testing.expectEqual(std.math.Order.lt, compareBytes("A", "a")); // 0x41 < 0x61 // 0x80 vs 0x7F: unsigned compare puts 0x80 above 0x7F (a signed i8 // compare would invert this, since 0x80 as i8 is negative). try std.testing.expectEqual( std.math.Order.gt, compareBytes(&[_]u8{0x80}, &[_]u8{0x7F}), ); // A precomposed vs. decomposed representation of the same visual // character (e.g. NFC "é" = 0xC3 0xA9 vs. NFD "e" + combining acute = // 0x65 0xCC 0x81) compares by raw octets, not by Unicode equivalence: // first byte 0xC3 > 0x65, so no normalization occurred. const nfc = [_]u8{ 0xC3, 0xA9 }; const nfd = [_]u8{ 0x65, 0xCC, 0x81 }; try std.testing.expectEqual(std.math.Order.gt, compareBytes(&nfc, &nfd)); } test "artifact §4.5.3-m1: shorter sorts before an extension, absent before present" { // Lines 829-830: "A shorter string sorts before any extension of // itself, and an absent value sorts before any present value." try std.testing.expectEqual(std.math.Order.lt, compareBytes("abc", "abcd")); try std.testing.expectEqual(std.math.Order.lt, compareOptional(null, "x")); // Absent precedes even the empty string. try std.testing.expectEqual(std.math.Order.lt, compareOptional(null, "")); try std.testing.expectEqual(std.math.Order.eq, compareOptional(null, null)); try std.testing.expectEqual(std.math.Order.eq, compareOptional("", "")); } test "artifact §4.5.3-m2: the directory separator receives no special treatment" { // Line 831: "The directory separator / (0x2F) receives no special // treatment." try std.testing.expectEqual(std.math.Order.lt, compareBytes("a.b", "a/b")); // 0x2E < 0x2F try std.testing.expectEqual(std.math.Order.lt, compareBytes("a/", "a0")); // 0x2F < 0x30 // Shorter-first still wins even though '/' would otherwise look like a // path-component boundary worth privileging. try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "a/b")); } test "artifact §4.6.1-r1: an empty decoded name is rejected" { // Line 862: "it is empty". try std.testing.expect(!isSimplePathname("")); } test "artifact §4.6.1-r2: a leading slash is rejected" { // Line 864: "its first byte is /". try std.testing.expect(!isSimplePathname("/foo")); try std.testing.expect(!isSimplePathname("/")); } test "artifact §4.6.1-r3: the whole name being . or .. is rejected" { // Line 866: "the whole string is . or ..". try std.testing.expect(!isSimplePathname(".")); try std.testing.expect(!isSimplePathname("..")); } test "artifact §4.6.1-r4: a name beginning ./ or ../ is rejected" { // Line 868: "it begins with ./ or ../". try std.testing.expect(!isSimplePathname("./a")); try std.testing.expect(!isSimplePathname("../a")); } test "artifact §4.6.1-r5: an embedded // is rejected" { // Line 870: "it contains //". try std.testing.expect(!isSimplePathname("a//b")); } test "artifact §4.6.1-r6: a trailing slash is rejected" { // Line 872: "it ends with /". try std.testing.expect(!isSimplePathname("a/")); } test "artifact §4.6.1-r7: a . or .. path element is rejected" { // Lines 874-875: "any path element equals . or .. -- that is, it // contains /./ or /../, or ends with /. or /..". Table-driven over all // four element positions the RFC text distinguishes. const cases = [_][]const u8{ "a/./b", // interior /./ "a/../b", // interior /../ "a/.", // ends /. "a/..", // ends /.. }; for (cases) |name| try std.testing.expect(!isSimplePathname(name)); } test "artifact §4.6.1-p1: the predicate accepts well-formed simple pathnames" { // Positive-space / "if and only if" side of the procedure clause // (line 858). Accept vectors from the §A.3 (lines 2561-2565) and §A.4 // (lines 2618-2620) worked-example F-card filenames, plus a nested // path and two names that look dot-like but are not exact `.`/`..` // elements (r3/r7 are exact-match, so these must pass). const cases = [_][]const u8{ "alpha.txt", "beta.txt", "gamma.txt", "file-a.txt", "a/b/c", "...", ".hidden", }; for (cases) |name| try std.testing.expect(isSimplePathname(name)); } test "artifact §4.6.1-m1: byte content does not affect acceptance" { // Lines 877-880: "backslashes, spaces ... control bytes, and // arbitrary or invalid UTF-8 sequences all pass. Accept or reject is // decidable from the separator structure alone." const accept_cases = [_][]const u8{ "a\\b", // backslash 0x5C "a b", // space 0x20, reachable via \s decoding "a\x00b", // control byte "a\xffb", // arbitrary high byte "\xc3\x28", // invalid UTF-8 (lone continuation-less lead byte) }; for (accept_cases) |name| try std.testing.expect(isSimplePathname(name)); // A hostile name with bad separator structure still rejects, so // acceptance above is not vacuous permissiveness. try std.testing.expect(!isSimplePathname("a\xff/../b")); } test "artifact §4.6.1-m2: the writer profile does not constrain acceptance" { // Lines 888-890: "A validator of existing artifacts MUST apply only // the parser rules; the writer profile does not constrain acceptance." // This name violates both writer-only bans (lines 882-887): it has a // backslash and an invalid UTF-8 byte. The validator still accepts it // because its separator structure is clean. try std.testing.expect(isSimplePathname("dir/a\\\xffb.txt")); } test "artifact §4.6.2-r1: a leading space, control byte, or empty name is rejected" { // Lines 907-908: "its first byte is less than or equal to 0x20 (this // rejects the empty string, a leading space, and any leading control // byte)". try std.testing.expect(!isWikiName("")); try std.testing.expect(!isWikiName(" abc")); // leading 0x20 try std.testing.expect(!isWikiName("\x00abc")); // leading NUL try std.testing.expect(!isWikiName("\x1fabc")); // leading control } test "artifact §4.6.2-r2: a control byte after the first is rejected" { // Lines 910-911: "any subsequent byte is less than 0x20 (all control // bytes, including tab and LF)". const cases = [_][]const u8{ "a\tb", // 0x09 tab "a\nb", // 0x0A LF "a\x1fb", // interior control "ab\x01", // trailing control }; for (cases) |name| try std.testing.expect(!isWikiName(name)); } test "artifact §4.6.2-r3: two consecutive spaces are rejected" { // Line 913: "two consecutive space bytes (0x20 0x20) appear anywhere". try std.testing.expect(!isWikiName("a b")); // interior double space try std.testing.expect(!isWikiName("ab cd")); // triple contains a pair } test "artifact §4.6.2-r4: a trailing space is rejected" { // Line 915: "its final byte is a space (0x20)". try std.testing.expect(!isWikiName("abc ")); try std.testing.expect(!isWikiName("a b ")); } test "artifact §4.6.2-r5: a length outside 1 through 100 is rejected" { // Line 917: "its total length in bytes is outside 1 through 100 // inclusive." Boundary accepts (1-byte and 100-byte) live in the p1 // test to keep the iff both-sided. try std.testing.expect(!isWikiName("")); // 0 bytes try std.testing.expect(!isWikiName("x" ** 101)); // 101 bytes } test "artifact §4.6.2-p1: the predicate accepts well-formed wiki names" { // Positive/"if and only if" side of the procedure clause (line // 903-905). const cases = [_][]const u8{ "A", // r5 lower bound, 1 byte "Wiki Page", "a b c", // single interior spaces are fine "x" ** 100, // r5 upper bound, 100 bytes }; for (cases) |name| try std.testing.expect(isWikiName(name)); } test "artifact §4.6.2-m1: bytes >= 0x21 are unconstrained; length is bytes not characters" { // Lines 919-921: "All bytes greater than or equal to 0x21 are // otherwise unconstrained: 0x7F and all bytes at or above 0x80 are // accepted, no UTF-8 validity check is performed, and length is // counted in bytes, not characters." const accept_cases = [_][]const u8{ "a\x7fb", // 0x7F "a\x80b", // >=0x80 "\xff\xfe", // arbitrary high bytes "\xc3\x28", // invalid UTF-8 (lone continuation-less lead byte) }; for (accept_cases) |name| try std.testing.expect(isWikiName(name)); // Length-in-bytes proof: 50 two-byte sequences = 100 bytes accepts; // 51 = 102 bytes rejects even though it is only 51 "characters". try std.testing.expect(isWikiName("\xc3\xa9" ** 50)); try std.testing.expect(!isWikiName("\xc3\xa9" ** 51)); // A control byte still rejects even amid high bytes, so acceptance // above is not vacuous permissiveness. try std.testing.expect(!isWikiName("a\x80\x01")); } test "artifact §4.6.3-p1: one sidecar suffix stripped, case-insensitively" { // Line 929: "ends, case-insensitively, with exactly one of -wal, -shm, // or -journal, strip that one suffix". "_FOSSIL_-WAL" strips to // "_FOSSIL_" (mixed case), which is then the whole-string tail (p2/p3a) // => reserved. try std.testing.expect(isReservedFilename("_FOSSIL_-WAL")); // "Exactly one": only the LAST-matching suffix strips. "-shm" strips // first (it's the trailing one), leaving "_FOSSIL_-wal", which ends // with neither reserved tail (line 934) => p2 fails => not reserved. try std.testing.expect(!isReservedFilename("_FOSSIL_-wal-shm")); } test "artifact §4.6.3-p2: reserved tail matched case-insensitively" { // Lines 933-934: "_FOSSIL_ (8 bytes) or .fslckout (9 bytes)", // case-insensitive. try std.testing.expect(isReservedFilename("_fossil_")); // lowercase tail try std.testing.expect(isReservedFilename(".FSLCKOUT")); // uppercase tail // No reserved tail present at all => p2 fails. try std.testing.expect(!isReservedFilename("readme.txt")); } test "artifact §4.6.3-p3: tail qualifies only via whole-string, slash, or sidecar" { // Line 936: "the reserved tail is the entire remaining string". try std.testing.expect(isReservedFilename("_FOSSIL_")); try std.testing.expect(isReservedFilename(".fslckout")); // Line 937: "the byte immediately before the tail is /". try std.testing.expect(isReservedFilename("dir/_FOSSIL_")); try std.testing.expect(isReservedFilename("dir/.fslckout")); } test "artifact §4.6.3-r1: a tail match satisfying no step-3 condition is not reserved" { // Line 940, verbatim worked counter-example: "A tail match that // satisfies none of step 3, such as x_FOSSIL_, is not reserved." Not // the whole string, the byte before the tail is 'x' not '/', and no // sidecar suffix was stripped. try std.testing.expect(!isReservedFilename("x_FOSSIL_")); } test "artifact §4.6.3-w1: a sidecar-suffix match is reserved via the step-1 strip" { // Lines 941-942, verbatim worked examples: "A sidecar-suffix match such // as x_FOSSIL_-wal or dir/.fslckout-journal is reserved because step 1 // stripped a suffix." Neither would qualify under 3a/3b alone (tail // isn't the whole string and isn't preceded by '/' after the strip // leaves "x_FOSSIL_" / "dir/.fslckout"), so only 3c (sidecar stripped) // explains the reserved verdict. try std.testing.expect(isReservedFilename("x_FOSSIL_-wal")); try std.testing.expect(isReservedFilename("dir/.fslckout-journal")); } test "artifact §4.6.3-m1: excludes platform checkout filters and Windows device names" { // Lines 943-945: "The predicate is limited to Fossil repository // metadata filenames; it does not include platform checkout filters or // Windows device names such as CON." CON has no reserved tail, so p2 // fails outright -- this locks the scope, not merely a coincidence of // step 3. try std.testing.expect(!isReservedFilename("CON")); } test "artifact §4.6.3-r2: reserved-ness is a bool drop signal, never a parse error" { // Line 961: "MUST be silently dropped, not rejected." Locked // structurally: the return type is bool, not an error union, so there // is no rejection variant for a reserved name to be mistaken for. try std.testing.expectEqual(bool, @TypeOf(isReservedFilename("_FOSSIL_"))); try std.testing.expect(isReservedFilename("_FOSSIL_")); } test "artifact §4.6.3: sidecar strip with no remaining reserved tail is not reserved" { // "foo-wal" strips to "foo" (step 1), which ends with neither reserved // tail (step 2 fails) => not reserved. Covers the sidecar-strip path // that does NOT lead to a match. try std.testing.expect(!isReservedFilename("foo-wal")); } test "artifact §4.6.3: the empty string is not reserved" { // No reserved tail can match an empty remaining string; also exercises // the underflow-guard boundary (remaining.len < tail_len). try std.testing.expect(!isReservedFilename("")); } test "artifact §4.6.3-m2: sort check runs before the reserved-name drop" { // Lines 967-969: "the F card sort check (Section 4.5.2) runs before // the reserved-name drop, so a reserved-named F card must itself be in // sort position relative to the previously retained entry." Anchor // starts at "z"; "_FOSSIL_" is reserved but sorts BEFORE "z" // ('_' 0x5F < 'z' 0x7A), so the order check rejects it first -- it is // never reached far enough to be dropped. var adm = FCardAdmission{}; try std.testing.expect(try adm.admit("z")); try std.testing.expectError(FCardSortError.OutOfOrder, adm.admit("_FOSSIL_")); // Reserved-ness alone never rescues an out-of-order name: "a" is not // reserved and is also out of order against anchor "z". try std.testing.expectError(FCardSortError.OutOfOrder, adm.admit("a")); } test "artifact §4.6.3-m2: the dropped name never becomes the comparison anchor" { // Lines 969-972: "the next F card is compared against that same // retained entry. The dropped name never becomes the comparison // anchor." "dir/_FOSSIL_" is in sort order after "a" (passes §4.5.2-r1) // and is reserved (3b, preceded by '/'), so it is dropped -- anchor // stays "a". "b" then proves the anchor is still "a": "b" sorts after // "a" but BEFORE "dir/_FOSSIL_" ('b' 0x62 < 'd' 0x64), so if the dropped // name had wrongly become the anchor, "b" would fail the order check. var adm = FCardAdmission{}; try std.testing.expect(try adm.admit("a")); try std.testing.expect(!(try adm.admit("dir/_FOSSIL_"))); // dropped try std.testing.expect(try adm.admit("b")); // compared against "a", not "dir/_FOSSIL_" } test "artifact §4.6.3-r2: a dropped reserved F card is removed from the effective file set" { // Lines 961-963: "An F card whose decoded current filename is reserved // MUST be silently dropped, not rejected. Dropping removes the card // from the effective file set." Feed three cards in wire order and // collect the retained ones: the reserved middle card must be absent // from the resulting set. const wire_order = [_][]const u8{ "a", "dir/_FOSSIL_", "b" }; var effective_set: [wire_order.len][]const u8 = undefined; var effective_len: usize = 0; var adm = FCardAdmission{}; for (wire_order) |name| { // bound: wire_order.len, fixed at 3 if (try adm.admit(name)) { std.debug.assert(effective_len < wire_order.len); effective_set[effective_len] = name; effective_len += 1; } } try std.testing.expectEqual(@as(usize, 2), effective_len); try std.testing.expectEqualStrings("a", effective_set[0]); try std.testing.expectEqualStrings("b", effective_set[1]); } test "artifact §4.6.3: the rule tests only the current filename, never old-name" { // Lines 957-959: "This rule applies only to the current filename field // of an F card, after escape decoding. The old-name field is not // tested by this reserved-name rule." Reinforced at lines 1202-1203. // Simulate a rename FROM a reserved name TO a plain one: old-name // "_FOSSIL_" is reserved (sanity check below), but `admit` has exactly // one filename parameter -- the decoded current name -- so old-name // never reaches the check. The card is retained, not dropped. const old_name = "_FOSSIL_"; try std.testing.expect(isReservedFilename(old_name)); // would drop, if tested var adm = FCardAdmission{}; try std.testing.expect(try adm.admit("plain.txt")); // current name only: not reserved } test "artifact §4.6.4-p1: the token is classified raw, never escape-decoded" { // p1: "The D and E card timestamps are each a single raw (never // decoded) token." Feed a wire token containing a literal backslash // (0x5C) followed by 't' (0x74) where a decoded tab would sit: if this // were escape-decoded first, \t -> 0x09 (whitespace) would satisfy the // m1 separator and the token would classify as date_time. Undecoded, the // separator scan sees 0x5C, which is neither 'T' nor whitespace, so the // date-time branch has zero separator bytes and rejects. const token = "2017-12-06\\t15:36:24"; try std.testing.expect(classifyTimestamp(token) == null); } test "artifact §4.6.4-m1: date-time accepts T and whitespace separators, optional :SS/.frac" { // m1: "a date-time: an optional leading -, then YYYY-MM-DD, then one or // more separator bytes each of which is either a whitespace-class byte // or the uppercase letter T, then HH:MM, HH:MM:SS, or HH:MM:SS. followed // by one or more digits" const cases = [_][]const u8{ "2017-12-06T15:36:24", "2017-12-06T15:36", "2017-12-06T15:36:24.287", // w1 "2017-12-06\x0915:36:24", // tab separator (whitespace class) "2017-12-06TT15:36", // 1+ separator bytes "-2017-12-06T15:36:24", // optional leading '-' }; for (cases) |c| { // bound: cases.len, fixed at 6 try std.testing.expect(classifyTimestamp(c).? == .date_time); } } test "artifact §4.6.4-m2: optional timezone, +HH:MM / -HH:MM / Z / z, optional leading ws" { // m2: "then an optional timezone of optional whitespace plus +HH:MM, // -HH:MM, Z, or z" const cases = [_][]const u8{ "2017-12-06T15:36:24+05:30", "2017-12-06T15:36:24-08:00", "2017-12-06T15:36:24Z", "2017-12-06T15:36:24z", "2017-12-06T15:36:24\x09Z", // optional whitespace before tz }; for (cases) |c| { // bound: cases.len, fixed at 5 try std.testing.expect(classifyTimestamp(c).? == .date_time); } } test "artifact §4.6.4-r1: lowercase t as the separator is rejected" { // r1 verbatim: "Lowercase t as the separator is rejected." try std.testing.expect(classifyTimestamp("2017-12-06t15:36:24") == null); } test "artifact §4.6.4-m3: date only, with and without leading -" { // m3: "A date only: optional leading -, then YYYY-MM-DD." try std.testing.expect(classifyTimestamp("2017-12-06").? == .date); try std.testing.expect(classifyTimestamp("-2017-12-06").? == .date); } test "artifact §4.6.4-m4: time only is interpreted on 2000-01-01" { // m4: "A time only: HH:MM, optionally :SS and a fraction, interpreted // as a time on 2000-01-01." Year 2000 is always well past the r3 // boundary, so the gate never has anything to reject for this form. const cases = [_][]const u8{ "15:36", "15:36:24", "15:36:24.287" }; for (cases) |c| { // bound: cases.len, fixed at 3 try std.testing.expect(classifyTimestamp(c).? == .time); } } test "artifact §4.6.4-m5: now/subsec/subsecond, case-insensitive" { // m5 verbatim: "The literals now, subsec, or subsecond, case-insensitive." const cases = [_][]const u8{ "now", "NOW", "Now", "subsec", "SubSec", "subsecond", "SUBSECOND" }; for (cases) |c| { // bound: cases.len, fixed at 7 try std.testing.expect(classifyTimestamp(c).? == .relative); } } test "artifact §4.6.4-m6: bare decimal accepted as Julian day iff 0 < r < 5373484.5" { // m6 (frozen RFC L1014): "value r satisfies 0 < r < 5373484.5." try std.testing.expect(classifyTimestamp("2451545").? == .julian); try std.testing.expect(classifyTimestamp("2451545.0").? == .julian); try std.testing.expect(classifyTimestamp("0.5").? == .julian); try std.testing.expect(classifyTimestamp("5373484.4").? == .julian); try std.testing.expect(classifyTimestamp("5373484.5") == null); // upper bound exclusive try std.testing.expect(classifyTimestamp("5373485") == null); } test "artifact §4.6.4-m7: exact digit counts and field ranges" { // m7 verbatim: "the year is exactly 4 digits, the month exactly 2 // digits in 01-12, the day exactly 2 digits in 01-31 with no // calendar-aware check (2024-02-31 is accepted), the hour 2 digits in // 00-24, the minute and second 2 digits each in 00-59, and the fraction // one or more digits." const rejects = [_][]const u8{ "2017-1-06", // month 1 digit "2017-13-06", // month 13 "2017-12-00", // day 00 "2017-12-32", // day 32 "2017-12-06T25:36", // hour 25 "2017-12-06T15:60", // minute 60 "2017-12-06T15:36:60", // second 60 "201-12-06", // year 3 digits "20171-12-06", // year 5 digits "2017-12-06T15:36:24.", // fraction 0 digits }; for (rejects) |c| { // bound: rejects.len, fixed at 10 try std.testing.expect(classifyTimestamp(c) == null); } try std.testing.expect(classifyTimestamp("2017-12-31").? == .date); try std.testing.expect(classifyTimestamp("2024-02-31").? == .date); // no calendar check try std.testing.expect(classifyTimestamp("2017-12-06T24:00").? == .date_time); // hour 24 in 00-24 } test "artifact §4.6.4-r3: JD>0 gate at the -4713-11-24 boundary and numeric 0" { // r3: "the token's value expressed as a Julian day number MUST be // greater than zero; this additionally rejects dates at or before // -4713-11-24 and the numeric token 0." RESOLVED (ambiguities.md, // 2026-07-09): date-granularity reading -- reject iff // (year,month,day) <= (-4713,11,24), independent of time-of-day. const rejects = [_][]const u8{ "-4713-11-24", "-4713-11-24T18:00:00", // any time on the boundary date still rejects "-4713-11-23", "-4713-10-31", "-4714-01-01", "0", "0.0", }; for (rejects) |c| { // bound: rejects.len, fixed at 7 try std.testing.expect(classifyTimestamp(c) == null); } try std.testing.expect(classifyTimestamp("-4713-11-25").? == .date); try std.testing.expect(classifyTimestamp("-4713-12-01").? == .date); try std.testing.expect(classifyTimestamp("-4712-01-01").? == .date); } test "artifact §4.6.4-m8: the writer form is the interoperable absolute date-time" { // m8: "For stored structural artifacts whose D or E card timestamp can // feed repository-state ordering, a producer MUST emit the absolute // date-time form above." isWriterTimestamp is the oracle for that exact // canonical subset: YYYY-MM-DDTHH:MM:SS[.frac], no sign, no tz. try std.testing.expect(isWriterTimestamp("2017-12-06T15:36:24.287")); // w1 try std.testing.expect(isWriterTimestamp("2017-12-06T15:36:24")); const rejects = [_][]const u8{ "2017-12-06T15:36", // no :SS "2017-12-06", // date only "2017-12-06T15:36:24Z", // tz present "-2017-12-06T15:36:24", // leading sign "2017-12-06\x0915:36:24", // non-'T' separator }; for (rejects) |c| { // bound: rejects.len, fixed at 5 try std.testing.expect(!isWriterTimestamp(c)); } } test "artifact §4.6.4-s1: a reader accepts the broader forms 2 through 5" { // s1: "A reader MAY accept the broader forms in items 2 through 5 for // compatibility." Realized as classifyTimestamp acceptance of one // representative token per non-date-time form. try std.testing.expect(classifyTimestamp("2017-12-06").? == .date); try std.testing.expect(classifyTimestamp("15:36:24").? == .time); try std.testing.expect(classifyTimestamp("now").? == .relative); try std.testing.expect(classifyTimestamp("2451545").? == .julian); } test "artifact §4.6.4-m9: relative literals are classified, never resolved against a clock" { // m9: "now, subsec, and subsecond are resolved against parse-time clock // state rather than denoting a stable instant in the artifact bytes. // Two ingestions of identical bytes can therefore yield different // stored mtime values." classifyTimestamp does no clock resolution at // all (that's [FossilRepoState]'s job); it is pure, so two calls on the // identical bytes always agree on the FORM even though a later resolver // may pick different instants. try std.testing.expect(classifyTimestamp("now").? == .relative); try std.testing.expect(classifyTimestamp("subsec").? == .relative); try std.testing.expect(classifyTimestamp("subsecond").? == .relative); try std.testing.expect(classifyTimestamp("now").? == classifyTimestamp("now").?); } test "artifact §4.6.4-w1: the technote worked example's D card timestamp" { // w1 verbatim: "D 2017-12-06T15:36:24.287" -- the token after the card // letter and space is the date-time form with a fractional second, and // it is also the exact writer-canonical form (no sign, exactly 'T', SS // present, no tz). const token = "2017-12-06T15:36:24.287"; try std.testing.expect(classifyTimestamp(token).? == .date_time); try std.testing.expect(isWriterTimestamp(token)); } const A_CARD_HASH40: []const u8 = "0123456789" ** 4; // 40 hex chars const A_CARD_HASH64: []const u8 = "0123456789abcdef" ** 4; // 64 hex chars comptime { std.debug.assert(A_CARD_HASH40.len == 40); std.debug.assert(A_CARD_HASH64.len == 64); } test "artifact §4.7.2-p1: A card accepts filename+target and filename+target+source" { // Grammar: `a-card = "A" SP filename SP target [ SP source ] LF` // (line 1074) -- both the 2-token and 3-token forms are valid. var buf: [256]u8 = undefined; const two = tokenizeCardLine("A alpha.txt " ++ A_CARD_HASH40 ++ "\n").?; const parsed_two = parseACard(two, &buf).?; try std.testing.expectEqualStrings("alpha.txt", parsed_two.name); try std.testing.expect(parsed_two.source == null); const three = tokenizeCardLine("A alpha.txt " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?; const parsed_three = parseACard(three, &buf).?; try std.testing.expectEqualStrings("alpha.txt", parsed_three.name); try std.testing.expectEqualStrings(A_CARD_HASH64, parsed_three.source.?); } test "artifact §4.7.2-p1/§4.2.1: A card arity is exact" { // p1: filename and target are both required. §4.2.1 line 610: "a // fourth on A" -- the grammar has no room for a fourth token. var buf: [256]u8 = undefined; const zero = tokenizeCardLine("A\n").?; try std.testing.expect(parseACard(zero, &buf) == null); const one = tokenizeCardLine("A alpha.txt\n").?; try std.testing.expect(parseACard(one, &buf) == null); const four = tokenizeCardLine("A alpha.txt " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ " extra\n").?; try std.testing.expect(parseACard(four, &buf) == null); } test "artifact §4.7.2-p1/§4.5.1: a second A card is a syntax error" { // "a second A card is a syntax error" (line 1077); A is single- // occurrence (occurrenceClass('A') == .single, line 575). var set = SingleOccurrenceSet{}; try std.testing.expect(set.admit('A')); try std.testing.expect(!set.admit('A')); } test "artifact §4.7.2-r1: the filename must pass the pathname predicate" { // "MUST pass the pathname predicate (Section 4.6.1)" (line 1083). var buf: [256]u8 = undefined; const cases = [_][]const u8{ "A /etc " ++ A_CARD_HASH40 ++ "\n", // leading '/' (§4.6.1-r2) "A " ++ A_CARD_HASH40 ++ "\n", // empty filename token (§4.6.1-r1) "A a/../b " ++ A_CARD_HASH40 ++ "\n", // path element .. (§4.6.1-r7) }; for (cases) |line| { const card = tokenizeCardLine(line).?; try std.testing.expect(parseACard(card, &buf) == null); } } test "artifact §4.7.2-m1: only the final path component is retained" { // "Only its final path component is retained ... any directory // prefix is discarded" (lines 1084-1086). var buf: [256]u8 = undefined; const cases = [_]struct { line: []const u8, want: []const u8 }{ .{ .line = "A dir/sub/f.txt " ++ A_CARD_HASH40 ++ "\n", .want = "f.txt" }, .{ .line = "A a/b " ++ A_CARD_HASH40 ++ "\n", .want = "b" }, .{ .line = "A x " ++ A_CARD_HASH40 ++ "\n", .want = "x" }, }; for (cases) |c| { const card = tokenizeCardLine(c.line).?; const parsed = parseACard(card, &buf).?; try std.testing.expectEqualStrings(c.want, parsed.name); } } test "artifact §4.7.2-r2: target is a valid hash or well-formed wiki name" { // "syntactically either a valid artifact hash or a well-formed wiki // name" (lines 1088-1089). var buf: [256]u8 = undefined; const accept = [_][]const u8{ "A x " ++ A_CARD_HASH40 ++ "\n", "A x " ++ A_CARD_HASH64 ++ "\n", "A x My\\sPage\n", // decodes to "My Page", a well-formed wiki name }; for (accept) |line| { const card = tokenizeCardLine(line).?; try std.testing.expect(parseACard(card, &buf) != null); } // Fails both alternatives: not 40/64 hex, and >100 bytes (wiki r5). const reject_line = "A x " ++ ("g" ** 101) ++ "\n"; const reject_card = tokenizeCardLine(reject_line).?; try std.testing.expect(parseACard(reject_card, &buf) == null); } test "artifact §4.7.2-r3: a present source is a raw, never-decoded artifact hash" { // "a raw (never decoded) artifact hash validated per the hash map" // (line 1093); §4.3.1 line 680: "but not the source hash". var buf: [256]u8 = undefined; const accept40 = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH40, parseACard(accept40, &buf).?.source.?); const accept64 = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH64, parseACard(accept64, &buf).?.source.?); const reject = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " xyz\n").?; try std.testing.expect(parseACard(reject, &buf) == null); // Decode asymmetry: the identical 41 wire bytes that decode to a // 40-hex TARGET (previous test) are read RAW in the source slot -- 41 // raw bytes match neither 40 nor 64, so this rejects. const wire = "\\a" ++ ("0" ** 39); const asymmetry = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " " ++ wire ++ "\n").?; try std.testing.expect(parseACard(asymmetry, &buf) == null); } test "artifact §4.7.2-m2: an absent source is empty (null)" { // "When absent, the source field is empty" (lines 1094-1095). var buf: [256]u8 = undefined; const card = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseACard(card, &buf).?.source == null); } test "artifact §4.7.3-p1: B card accepts one hash token, 40 or 64 hex" { // "Exactly one hash token, validated per the hash map" (line 1129). const forty = tokenizeCardLine("B " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH40, parseBCard(forty).?.hash); const sixtyfour = tokenizeCardLine("B " ++ A_CARD_HASH64 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH64, parseBCard(sixtyfour).?.hash); // A second token has no grammar slot to land in. const extra = tokenizeCardLine("B " ++ A_CARD_HASH40 ++ " x\n").?; try std.testing.expect(parseBCard(extra) == null); } test "artifact §4.7.3-r1: b-card grammar rejects bad arity and non-hash" { // `b-card = %x42 SP hash LF` (line 1127). try std.testing.expect(parseBCard(tokenizeCardLine("B\n").?) == null); try std.testing.expect(parseBCard(tokenizeCardLine("B " ++ A_CARD_HASH40 ++ " x\n").?) == null); try std.testing.expect(parseBCard(tokenizeCardLine("B xyz\n").?) == null); try std.testing.expect(parseBCard(tokenizeCardLine("B " ++ A_CARD_HASH40[0..39] ++ "\n").?) == null); } test "artifact §4.7.4-p1: C requires exactly one escape-decoded comment" { // A.1.2 (line 2471): "C Test\stech-note" decodes to "Test tech-note". var buf: [256]u8 = undefined; const card = tokenizeCardLine("C Test\\stech-note\n").?; try std.testing.expectEqualStrings("Test tech-note", parseCCard(card, &buf).?.comment); // Two tokens: no second grammar slot. const two_tok = tokenizeCardLine("C a b\n").?; try std.testing.expect(parseCCard(two_tok, &buf) == null); } test "artifact §4.7.4-r1 + §4.2 erratum: a C card with NO token is rejected; an EMPTY one is a present, empty comment" { // "a C card with no token MUST be rejected" (line 1143) — and the // distinction that sentence turns on is ABSENT vs EMPTY, which the // reference implementation draws positionally: `next_token` returns NULL // only at end-of-line. So `C` LF has no token and is rejected, while // `C` SP LF has one empty token and is a comment whose decoded value is // the empty string. §4.2's `token = 1*tchar` reads as forbidding the // second form; the erratum (#86, // docs/rfcs/erratum/artifact-format-00-s4.2-empty-trailing-token.md) // records that the grammar, not the corpora, is what is wrong. This test // previously asserted the opposite of the second line and so encoded the // defect — rejecting the empty form drops the entire check-in. var buf: [256]u8 = undefined; try std.testing.expect(parseCCard(tokenizeCardLine("C\n").?, &buf) == null); const empty = parseCCard(tokenizeCardLine("C \n").?, &buf) orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("", empty.comment); // A second token is still a second token, empty or not. try std.testing.expect(parseCCard(tokenizeCardLine("C a b\n").?, &buf) == null); } test "artifact §4.7.4-m1: the decoded comment is otherwise unvalidated" { // "The decoded comment content is otherwise unvalidated" (lines // 1143-1144) -- a comment decoding to control bytes still parses. var buf: [256]u8 = undefined; const card = tokenizeCardLine("C \\t\\n\n").?; try std.testing.expectEqualStrings("\t\n", parseCCard(card, &buf).?.comment); } const D_CARD_TS = "2017-12-06T15:36:24.287"; // A.1.2 line 2472 const E_CARD_ID = "bfdf42305400f6caa441fa816cb89231e90f8c82"; // A.1.2 line 2473, 40 hex test "artifact §4.7.5-p1: D and E timestamps validate per §4.6.4 (raw)" { // "Each timestamp is a single raw token validated per Section 4.6.4" // (line 1153) -- .timestamp equals the wire bytes verbatim, never // decoded. const d = tokenizeCardLine("D " ++ D_CARD_TS ++ "\n").?; try std.testing.expectEqualStrings(D_CARD_TS, parseDCard(d).?.timestamp); const e = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ E_CARD_ID ++ "\n").?; try std.testing.expectEqualStrings(D_CARD_TS, parseECard(e).?.timestamp); } test "artifact §4.7.5-p2: E requires a technote-id per the hash map" { // `e-card = ... SP technote-id LF` (line 1151). EVENT-type marking is // §5.3 type resolution, deferred to the manifest-assembly batch. const with_id = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ E_CARD_ID ++ "\n").?; try std.testing.expectEqualStrings(E_CARD_ID, parseECard(with_id).?.technote_id); const missing_id = tokenizeCardLine("E " ++ D_CARD_TS ++ "\n").?; try std.testing.expect(parseECard(missing_id) == null); } test "artifact §4.7.5-r1: d-card / e-card grammars reject bad arity" { // Lines 1150-1151. try std.testing.expect(parseDCard(tokenizeCardLine("D\n").?) == null); try std.testing.expect(parseDCard(tokenizeCardLine("D " ++ D_CARD_TS ++ " b\n").?) == null); try std.testing.expect(parseECard(tokenizeCardLine("E\n").?) == null); try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ "\n").?) == null); try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ E_CARD_ID ++ " x\n").?) == null); } test "artifact §4.7.5-r2: a timestamp failing §4.6.4 is rejected" { // "validated per Section 4.6.4" (line 1152). try std.testing.expect(parseDCard(tokenizeCardLine("D \n").?) == null); // empty try std.testing.expect(parseDCard(tokenizeCardLine("D banana\n").?) == null); try std.testing.expect(parseDCard(tokenizeCardLine("D 2017-12-06t15:36:24\n").?) == null); // r1 lowercase t // RESOLVED date-granularity gate (§4.6.4, ambiguities.md): reject at or // before -4713-11-24 regardless of time-of-day. try std.testing.expect(parseDCard(tokenizeCardLine("D -4713-11-24T18:00:00\n").?) == null); } test "artifact §4.7.5-r3: E technote-id validated per the hash map (raw)" { // "a technote identifier validated per the hash map" (line 1152); // §4.7.1 -- hash/checksum positions are never escape-decoded. const accept40 = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseECard(accept40) != null); const accept64 = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH64 ++ "\n").?; try std.testing.expect(parseECard(accept64) != null); try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH40[0..39] ++ "\n").?) == null); try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH40 ++ "1\n").?) == null); try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " xyz\n").?) == null); // A wire token whose escape decode would be 40 hex is read RAW here -- // 41 raw bytes match neither 40 nor 64, so it rejects (raw != decoded). const wire_id = "\\a" ++ ("0" ** 39); try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ wire_id ++ "\n").?) == null); } test "artifact §4.7.5-w1: A.1.2 D and E cards parse" { // A.1.2, lines 2472-2473, exact bytes. const d = tokenizeCardLine("D 2017-12-06T15:36:24.287\n").?; const d_parsed = parseDCard(d).?; try std.testing.expectEqualStrings("2017-12-06T15:36:24.287", d_parsed.timestamp); const e = tokenizeCardLine("E 2013-08-26T07:35:00 bfdf42305400f6caa441fa816cb89231e90f8c82\n").?; const e_parsed = parseECard(e).?; try std.testing.expectEqualStrings("2013-08-26T07:35:00", e_parsed.timestamp); try std.testing.expectEqualStrings("bfdf42305400f6caa441fa816cb89231e90f8c82", e_parsed.technote_id); try std.testing.expectEqual(@as(usize, 40), e_parsed.technote_id.len); // SHA-1 } test "artifact §4.7.6-p1: F parses across its positional forms" { // `f-card = "F" SP filename [ SP hash [ SP perm [ SP old-name ] ] ] LF` // (line 1160-1161) -- 1/2/3/4-token forms all valid; a 5th rejects. var buf: [256]u8 = undefined; const one = tokenizeCardLine("F f\n").?; try std.testing.expect(parseFCard(one, &buf) != null); const two = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseFCard(two, &buf) != null); const three = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w\n").?; try std.testing.expect(parseFCard(three, &buf) != null); const four = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w old\n").?; try std.testing.expect(parseFCard(four, &buf) != null); const five = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w old extra\n").?; try std.testing.expect(parseFCard(five, &buf) == null); } test "artifact §4.7.6-p1: an empty perm token is rejected (token = 1*tchar)" { // `perm = token`, `token = 1*tchar` (Appendix B, lines 2846, 2852) -- no // production admits a zero-length token, so a trailing SP after the hash // (tokenizes to an empty 3rd token) must reject rather than silently // become perm=="". Same slip via a double SP ahead of old-name: the 3rd // token is still empty even though a 4th token follows. var buf: [256]u8 = undefined; try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " \n").?, &buf) == null); try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " old\n").?, &buf) == null); } // The legacy fossil F-card vector: check-in 32b480fa... writes 210/211 of its // F cards as `F <name> <hash><SP>` -- a single dangling trailing separator in // the perm-token position (bytes end 0x20 0x0A). `LEGACY_F_LINE` is a verbatim // line from that manifest; the body below is the smallest well-formed check-in // carrying it, so `buildArtifact` seals a Z that matches the trailing-SP bytes // exactly as fossil stored them. const LEGACY_F_HASH = "e7fed9d5b647337f8e7abf45981d10cdcc1555e2"; const LEGACY_F_BODY = "C interop\\stest\nD " ++ TEST_TS ++ "\nF BUILD.txt " ++ LEGACY_F_HASH ++ " \nU drh\n"; test "artifact §4.2/§4.7.6-p1: full parse rejects an F card with a trailing separator" { // Strict clause coverage at the whole-artifact level: a Z-valid manifest // whose only defect is the dangling perm separator (`token = 1*tchar`, no // production for an empty token) is still rejected by the reference parser. var buf: [256]u8 = undefined; const bytes = buildArtifact(buf[0..], LEGACY_F_BODY); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); } test "interop: import tolerates one trailing separator on a legacy fossil F card" { // NOT clause coverage -- a documented read-path interop concession. The // strict parser stays the §4.2 oracle (rejects, asserted above); the import // shim strips the one dangling SP, re-seals Z, and reconstructs the F card. var buf: [256]u8 = undefined; const bytes = buildArtifact(buf[0..], LEGACY_F_BODY); var im = try parseForImport(std.testing.allocator, bytes); defer im.deinit(std.testing.allocator); try std.testing.expect(im.normalized != null); // the tolerance fired try std.testing.expectEqual(ArtifactType.manifest, im.manifest.kind); try std.testing.expectEqual(@as(usize, 1), im.manifest.f.len); const fc = im.manifest.f[0]; try std.testing.expectEqualStrings("BUILD.txt", fc.filename); try std.testing.expectEqualStrings(LEGACY_F_HASH, fc.hash.?); try std.testing.expect(fc.perm == null); // empty perm read as regular file try std.testing.expectEqual(FileMode.regular, fc.mode); } test "interop: import rejects a double trailing separator (single-SP tolerance only)" { // The shim strips EXACTLY one dangling SP. Two trailing separators leave a // dangling SP after the strip, so the strict retry still rejects -- the // concession never widens past the one cvs2fossil-era shape. var buf: [256]u8 = undefined; const two_sp_body = "C x\nD " ++ TEST_TS ++ "\nF BUILD.txt " ++ LEGACY_F_HASH ++ " \nU drh\n"; const bytes = buildArtifact(buf[0..], two_sp_body); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); try std.testing.expectError(error.SyntaxError, parseForImport(std.testing.allocator, bytes)); } test "artifact §4.7.6-r1: the F filename is not optional" { var buf: [256]u8 = undefined; try std.testing.expect(parseFCard(tokenizeCardLine("F\n").?, &buf) == null); try std.testing.expect(parseFCard(tokenizeCardLine("F \n").?, &buf) == null); // empty } test "artifact §4.7.6-m1/r2: filename escape-decoded and held to §4.6.1" { // Line 1163-1164. var buf: [256]u8 = undefined; const good = tokenizeCardLine("F a\\sb " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expectEqualStrings("a b", parseFCard(good, &buf).?.filename); const bad_cases = [_][]const u8{ "F /etc " ++ A_CARD_HASH40 ++ "\n", // §4.6.1-r2 leading '/' "F a/../b " ++ A_CARD_HASH40 ++ "\n", // §4.6.1-r7 '..' "F " ++ A_CARD_HASH40 ++ "\n", // empty filename token }; for (bad_cases) |line| { try std.testing.expect(parseFCard(tokenizeCardLine(line).?, &buf) == null); } } test "artifact §4.7.6-r3: F hash, when present, is exactly 40 or 64 hex" { var buf: [256]u8 = undefined; try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "\n").?, &buf) != null); try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH64 ++ "\n").?, &buf) != null); try std.testing.expect(parseFCard(tokenizeCardLine("F f xyz\n").?, &buf) == null); try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40[0..39] ++ "\n").?, &buf) == null); try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "1\n").?, &buf) == null); } test "artifact §4.7.6-m2/r4: old-name decoded and held to §4.6.1" { var buf: [256]u8 = undefined; const good = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w a\\sb\n").?; try std.testing.expectEqualStrings("a b", parseFCard(good, &buf).?.old_name.?); const bad_cases = [_][]const u8{ "F f " ++ A_CARD_HASH40 ++ " w /etc\n", "F f " ++ A_CARD_HASH40 ++ " w a/../b\n", }; for (bad_cases) |line| { try std.testing.expect(parseFCard(tokenizeCardLine(line).?, &buf) == null); } } test "artifact §4.7.6-m3: hash and permission tokens are never decoded" { // Line 1166: "the hash and permission tokens are never decoded." var buf: [256]u8 = undefined; const card = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w\\sx\n").?; const parsed = parseFCard(card, &buf).?; try std.testing.expectEqualStrings(A_CARD_HASH40, parsed.hash.?); try std.testing.expectEqualStrings("w\\sx", parsed.perm.?); // backslash preserved raw } test "artifact §4.7.6-m4: third token is raw perm (no {w,x,l} check); fourth is old-name" { // Lines 1192-1196. var buf: [256]u8 = undefined; const weird_perm = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " zzz\n").?; const parsed_weird = parseFCard(weird_perm, &buf).?; try std.testing.expectEqualStrings("zzz", parsed_weird.perm.?); try std.testing.expect(parsed_weird.old_name == null); // 3-token: no old-name const four_tok = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w old\n").?; try std.testing.expectEqualStrings("old", parseFCard(four_tok, &buf).?.old_name.?); } test "artifact §4.7.6-w1: a hash-less F card parses (A.4.2)" { // A.4.2, line 2652, exact bytes: "F file-c.txt" with no continuation. var buf: [256]u8 = undefined; const card = tokenizeCardLine("F file-c.txt\n").?; const parsed = parseFCard(card, &buf).?; try std.testing.expectEqualStrings("file-c.txt", parsed.filename); try std.testing.expect(parsed.hash == null); try std.testing.expect(parsed.perm == null); try std.testing.expect(parsed.old_name == null); try std.testing.expectEqual(FileMode.regular, parsed.mode); } test "artifact §4.7.6-w2: F alpha.txt with SHA3-256 hash parses (A.3)" { // A.3, lines 2561-2562, exact bytes (unwrapped). var buf: [256]u8 = undefined; const card = tokenizeCardLine( "F alpha.txt a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138\n", ).?; const parsed = parseFCard(card, &buf).?; try std.testing.expectEqualStrings("alpha.txt", parsed.filename); try std.testing.expectEqualStrings( "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138", parsed.hash.?, ); try std.testing.expectEqual(@as(usize, 64), parsed.hash.?.len); } test "artifact §4.7.7-g-p1: G card accepts a raw 40- and 64-hex hash, never decoded" { // Line 1207, 1212-1214: raw hash, validated per the hash map. try std.testing.expectEqualStrings( A_CARD_HASH40, parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH40 ++ "\n").?).?.hash, ); try std.testing.expectEqualStrings( A_CARD_HASH64, parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH64 ++ "\n").?).?.hash, ); try std.testing.expect(parseGCard(tokenizeCardLine("G xyz\n").?) == null); // non-hex try std.testing.expect(parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH40[0..39] ++ "\n").?) == null); // wrong length } test "artifact §4.7.7-g-r1: G card rejects zero tokens and a second token" { // Line 1211: "single-occurrence with exactly one required token" -- // occurrence/type-marking are deferred; arity is checked here. try std.testing.expect(parseGCard(tokenizeCardLine("G\n").?) == null); try std.testing.expect(parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH40 ++ " x\n").?) == null); } test "artifact §4.7.7-i-p1: I card accepts a raw hash, never decoded" { // Line 1209, 1214-1215. try std.testing.expectEqualStrings( A_CARD_HASH40, parseICard(tokenizeCardLine("I " ++ A_CARD_HASH40 ++ "\n").?).?.hash, ); try std.testing.expectEqualStrings( A_CARD_HASH64, parseICard(tokenizeCardLine("I " ++ A_CARD_HASH64 ++ "\n").?).?.hash, ); try std.testing.expect(parseICard(tokenizeCardLine("I xyz\n").?) == null); // non-hex } test "artifact §4.7.7-i-r1: I card rejects zero tokens and a second token" { try std.testing.expect(parseICard(tokenizeCardLine("I\n").?) == null); try std.testing.expect(parseICard(tokenizeCardLine("I " ++ A_CARD_HASH40 ++ " x\n").?) == null); } test "artifact §4.7.7-h-p1: H card decodes the title and accepts any decoded content" { // Line 1216-1217: "no content validation -- any decoded string is // accepted." A title that fails the wiki-name predicate (leading space, // double space, >100 bytes) is still accepted by H; contrast the // parseLCard rejection test below. var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "thread title", parseHCard(tokenizeCardLine("H thread\\stitle\n").?, &buf).?.title, ); try std.testing.expectEqualStrings( " leading", parseHCard(tokenizeCardLine("H \\sleading\n").?, &buf).?.title, ); try std.testing.expectEqualStrings( "a b", parseHCard(tokenizeCardLine("H a\\s\\sb\n").?, &buf).?.title, ); const over_100 = "x" ** 101; try std.testing.expectEqualStrings( over_100, parseHCard(tokenizeCardLine("H " ++ over_100 ++ "\n").?, &buf).?.title, ); } test "artifact §4.7.7-h-r1: H card rejects zero tokens, an empty token, and a second token" { var buf: [256]u8 = undefined; try std.testing.expect(parseHCard(tokenizeCardLine("H\n").?, &buf) == null); try std.testing.expect(parseHCard(tokenizeCardLine("H \n").?, &buf) == null); // empty token try std.testing.expect(parseHCard(tokenizeCardLine("H a b\n").?, &buf) == null); } test "artifact §4.7.8-p1: J card parses fieldname and optional value" { var buf: [256]u8 = undefined; const with_value = parseJCard(tokenizeCardLine("J status open\n").?, &buf).?; try std.testing.expectEqualStrings("status", with_value.field); try std.testing.expectEqualStrings("open", with_value.value); const no_value = parseJCard(tokenizeCardLine("J foo\n").?, &buf).?; try std.testing.expectEqualStrings("foo", no_value.field); try std.testing.expectEqualStrings("", no_value.value); } test "artifact §4.7.8-m1: J value is escape-decoded, field name stored verbatim" { var buf: [256]u8 = undefined; const parsed = parseJCard(tokenizeCardLine("J comment a\\sb\n").?, &buf).?; try std.testing.expectEqualStrings("comment", parsed.field); try std.testing.expectEqualStrings("a b", parsed.value); } test "artifact §4.7.8-m2: a leading + in the J field name is preserved" { var buf: [256]u8 = undefined; const parsed = parseJCard(tokenizeCardLine("J +comment hi\n").?, &buf).?; try std.testing.expectEqualStrings("+comment", parsed.field); } test "artifact §4.7.8-m3: an absent J value is the empty string, not null" { var buf: [256]u8 = undefined; const parsed = parseJCard(tokenizeCardLine("J foo\n").?, &buf).?; try std.testing.expectEqual(@as(usize, 0), parsed.value.len); } test "artifact §4.7.8-r1 + §4.2 erratum: J rejects a missing field and a third token; an empty value is accepted" { var buf: [256]u8 = undefined; try std.testing.expect(parseJCard(tokenizeCardLine("J\n").?, &buf) == null); try std.testing.expect(parseJCard(tokenizeCardLine("J \n").?, &buf) == null); // empty FIELDNAME is still refused try std.testing.expect(parseJCard(tokenizeCardLine("J foo bar baz\n").?, &buf) == null); // third token // §4.2 erratum (#86): a trailing empty token is a present, empty value — // `if( zValue==0 ) zValue = "" ` in the reference implementation makes // absent and empty the same "" for J alone. sqlite's ticket corpus is full // of these, and rejecting them lost every ticket-change artifact. const empty_value = parseJCard(tokenizeCardLine("J comment \n").?, &buf) orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("comment", empty_value.field); try std.testing.expectEqualStrings("", empty_value.value); // Absent value decodes to the same "", so the two are indistinguishable — // deliberately, and unlike the T card, whose absent value stays null. const absent = parseJCard(tokenizeCardLine("J comment\n").?, &buf) orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("", absent.value); } test "artifact §4.7.9-p1: K card accepts a 40-hex ticket id, raw" { try std.testing.expectEqualStrings( A_CARD_HASH40, parseKCard(tokenizeCardLine("K " ++ A_CARD_HASH40 ++ "\n").?).?.ticket_id, ); } test "artifact §4.7.9-r1: K card rejects a 64-char SHA3-length id" { try std.testing.expect(parseKCard(tokenizeCardLine("K " ++ A_CARD_HASH64 ++ "\n").?) == null); } test "artifact §4.7.9-r2: K card rejects zero tokens and a second token" { try std.testing.expect(parseKCard(tokenizeCardLine("K\n").?) == null); try std.testing.expect(parseKCard(tokenizeCardLine("K " ++ A_CARD_HASH40 ++ " x\n").?) == null); } test "artifact §4.7.9-s1: K card takes the MAY as lowercase-only, rejecting uppercase" { // Mirrors validateHashToken(.ticket_id)'s lowercase-only decision // (line 1648 above) -- a1_1_name has hex letters, unlike A_CARD_HASH40's // all-digit fixture, so the uppercase transform is observable. var upper: [40]u8 = undefined; for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); var line: [43]u8 = undefined; // "K " + 40 + "\n" line[0] = 'K'; line[1] = ' '; @memcpy(line[2..42], &upper); line[42] = '\n'; try std.testing.expect(parseKCard(tokenizeCardLine(&line).?) == null); // Lowercase original still parses -- proving the reject is case-specific. try std.testing.expect(parseKCard(tokenizeCardLine("K " ++ a1_1_name ++ "\n").?) != null); } test "artifact §4.7.10-p1: L card decodes the title and requires the wiki-name predicate" { var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "Wiki Page", parseLCard(tokenizeCardLine("L Wiki\\sPage\n").?, &buf).?.title, ); } test "artifact §4.7.10-r1: L card rejects a decoded title failing the wiki-name predicate" { var buf: [256]u8 = undefined; try std.testing.expect(parseLCard(tokenizeCardLine("L \\sWiki\n").?, &buf) == null); // leading space (§4.6.2 r1) const over_100 = "x" ** 101; try std.testing.expect(parseLCard(tokenizeCardLine("L " ++ over_100 ++ "\n").?, &buf) == null); // §4.6.2 r5 } test "artifact §4.7.10-r2: L card rejects zero tokens and a second token" { var buf: [256]u8 = undefined; try std.testing.expect(parseLCard(tokenizeCardLine("L\n").?, &buf) == null); try std.testing.expect(parseLCard(tokenizeCardLine("L Wiki Page\n").?, &buf) == null); // second token } test "artifact §4.7.11-p1: M card accepts a raw 40- and 64-hex hash, never decoded" { // `m-card = %x4D SP hash LF` (line 1263); "validated per the hash map" // (line 1265-1266). Ordering (strict-ascending run) is §4.5.2-r3, // already tested there. const forty = tokenizeCardLine("M " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH40, parseMCard(forty).?.hash); const sixtyfour = tokenizeCardLine("M " ++ A_CARD_HASH64 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH64, parseMCard(sixtyfour).?.hash); } test "artifact §4.7.11-r1: M card rejects zero tokens, a second token, and a non-hash" { const bad = [_][]const u8{ "M\n", "M " ++ A_CARD_HASH40 ++ " x\n", "M xyz\n", "M " ++ A_CARD_HASH40[0..39] ++ "\n", // wrong length }; for (bad) |line| { try std.testing.expect(parseMCard(tokenizeCardLine(line).?) == null); } } test "artifact §4.7.12-p1: N card decodes the mimetype token" { // `n-card = %x4E SP mimetype LF` (line 1270). var buf: [256]u8 = undefined; const card = tokenizeCardLine("N application/json\n").?; try std.testing.expectEqualStrings("application/json", parseNCard(card, &buf).?.mimetype); } test "artifact §4.7.12-m1: N mimetype is escape-decoded with no mime-syntax validation" { // Escape decode applies (§4.3 Table 1 \s -> SP); a non-mimetype token // is accepted verbatim -- "no mime-syntax validation" (line 1272). var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "a b", parseNCard(tokenizeCardLine("N a\\sb\n").?, &buf).?.mimetype, ); try std.testing.expectEqualStrings( "zzz", parseNCard(tokenizeCardLine("N zzz\n").?, &buf).?.mimetype, ); } test "artifact §4.7.12-r1: N card rejects zero tokens, an empty token, and a second token" { var buf: [256]u8 = undefined; try std.testing.expect(parseNCard(tokenizeCardLine("N\n").?, &buf) == null); try std.testing.expect(parseNCard(tokenizeCardLine("N \n").?, &buf) == null); // token = 1*tchar try std.testing.expect(parseNCard(tokenizeCardLine("N a b\n").?, &buf) == null); } test "artifact §4.7.12-w1: N card of the §A.1.2 technote decodes to text/x-fossil-wiki" { // §A.1.2 line 2475: "N text/x-fossil-wiki" -- no escapes, identity decode. var buf: [256]u8 = undefined; const card = tokenizeCardLine("N text/x-fossil-wiki\n").?; try std.testing.expectEqualStrings("text/x-fossil-wiki", parseNCard(card, &buf).?.mimetype); } test "artifact §4.7.13-p1: P card parses parents in order, neither sorted nor deduped" { // "Tokens are neither sorted nor deduplicated; their order is // semantic" (line 1296). const H_FF = "f" ** 40; // sorts after A_CARD_HASH40 lexically const unsorted = tokenizeCardLine("P " ++ H_FF ++ " " ++ A_CARD_HASH40 ++ "\n").?; const p1 = parsePCard(unsorted).?; try std.testing.expectEqual(@as(usize, 2), p1.count); var it1 = p1.hashes(); try std.testing.expectEqualStrings(H_FF, it1.next().?); try std.testing.expectEqualStrings(A_CARD_HASH40, it1.next().?); try std.testing.expect(it1.next() == null); const dup = tokenizeCardLine("P " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expectEqual(@as(usize, 2), parsePCard(dup).?.count); } test "artifact §4.7.13-m1: an empty P card (zero tokens) MUST be accepted" { // "An empty P card (zero tokens) MUST be accepted" (line 1293-1295). const card = parsePCard(tokenizeCardLine("P\n").?).?; try std.testing.expectEqual(@as(usize, 0), card.count); try std.testing.expect(card.parents == null); } test "artifact §4.7.13-r1: P card rejects any token failing the hash map" { const bad = [_][]const u8{ "P xyz\n", // non-hash "P \n", // empty token is not a hash "P " ++ A_CARD_HASH40 ++ " zzz\n", // second token bad }; for (bad) |line| { try std.testing.expect(parsePCard(tokenizeCardLine(line).?) == null); } } test "artifact §4.7.13-w1: the §A.1.1 root check-in's empty P card parses to zero parents" { // §A.1.1 line 2421: the bare "P" line, described at 2430-2432 as the // root check-in's parent-less card. const card = parsePCard(tokenizeCardLine("P\n").?).?; try std.testing.expectEqual(@as(usize, 0), card.count); } test "artifact §4.7.14-p1: Q card parses q-sign, target hash, and optional baseline" { // `q-card = %x51 SP q-sign hash [ SP hash ] LF` (line 1325-1326). const cherry = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ "\n").?).?; try std.testing.expectEqual(QSign.cherrypick, cherry.sign); try std.testing.expectEqualStrings(A_CARD_HASH40, cherry.target); try std.testing.expect(cherry.baseline == null); const backout = parseQCard(tokenizeCardLine("Q -" ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?).?; try std.testing.expectEqual(QSign.backout, backout.sign); try std.testing.expectEqualStrings(A_CARD_HASH40, backout.target); try std.testing.expectEqualStrings(A_CARD_HASH64, backout.baseline.?); } test "artifact §4.7.14-m1: the sign byte is stripped and the remainder validated as a hash" { // Lines 1329-1332: sign byte checked, remainder validated per the // hash map; the '+'/'-' byte itself never appears in `target`. const plus = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ "\n").?).?; try std.testing.expectEqual(@as(usize, 40), plus.target.len); try std.testing.expectEqualStrings(A_CARD_HASH40, plus.target); try std.testing.expectEqual(QSign.cherrypick, plus.sign); const minus = parseQCard(tokenizeCardLine("Q -" ++ A_CARD_HASH64 ++ "\n").?).?; try std.testing.expectEqualStrings(A_CARD_HASH64, minus.target); try std.testing.expectEqual(QSign.backout, minus.sign); } test "artifact §4.7.14-m2: the optional second token is a plain unprefixed baseline hash" { // Line 1332-1334: baseline is a plain unprefixed hash. const card = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?).?; try std.testing.expectEqualStrings(A_CARD_HASH64, card.baseline.?); // A '+'-prefixed second token is a 41-byte string, not a valid hash. const prefixed = tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ " +" ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseQCard(prefixed) == null); } test "artifact §4.7.14-m3: an omitted baseline is left null, not resolved" { // Lines 1334-1336: "the field is left null" -- not resolved to the // primary parent at parse time. const card = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ "\n").?).?; try std.testing.expect(card.baseline == null); } test "artifact §4.7.14-r1: Q card rejects a q-sign other than + or -" { // `q-sign = "+" / "-"` (line 1327) -- Q forbids the '*' the T card // allows; a bare hash with no sign byte also rejects. try std.testing.expect(parseQCard(tokenizeCardLine("Q *" ++ A_CARD_HASH40 ++ "\n").?) == null); try std.testing.expect(parseQCard(tokenizeCardLine("Q " ++ A_CARD_HASH40 ++ "\n").?) == null); // `Q \n` tokenizes to one empty token (no sign byte at all, not even a // bad one) -- the length guard ahead of the sign switch, not the // switch's `else` arm, is what rejects this. try std.testing.expect(parseQCard(tokenizeCardLine("Q \n").?) == null); } test "artifact §4.7.14-r2: Q card rejects zero tokens and a third token" { try std.testing.expect(parseQCard(tokenizeCardLine("Q\n").?) == null); const three = tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ " " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseQCard(three) == null); } test "artifact §4.7.14-r3: Q card rejects a remainder or baseline failing the hash map" { const bad = [_][]const u8{ "Q +xyz\n", // bad remainder "Q +\n", // sign only, empty remainder "Q +" ++ A_CARD_HASH40 ++ " zzz\n", // bad baseline }; for (bad) |line| { try std.testing.expect(parseQCard(tokenizeCardLine(line).?) == null); } } const MD5_32: []const u8 = "0123456789abcdef" ** 2; // 32 hex chars comptime { std.debug.assert(MD5_32.len == 32); } test "artifact §4.7.15-p1: R card accepts a 32-hex checksum, raw and never decoded" { const card = tokenizeCardLine("R " ++ MD5_32 ++ "\n").?; const parsed = parseRCard(card).?; try std.testing.expectEqualStrings(MD5_32, parsed.checksum); try std.testing.expectEqual(@as(usize, 32), parsed.checksum.len); // §6.3.1-p1/-m1: no file content is present here at all, yet the card // still parses -- the parse path never recomputes against file content. } test "artifact §4.7.15-r1: R card grammar rejects bad arity and a non-checksum" { try std.testing.expect(parseRCard(tokenizeCardLine("R\n").?) == null); // zero tokens const second = tokenizeCardLine("R " ++ MD5_32 ++ " " ++ MD5_32 ++ "\n").?; try std.testing.expect(parseRCard(second) == null); // second token try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ A_CARD_HASH40 ++ "\n").?) == null); // 40 hex, not 32 try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32[0..31] ++ "g\n").?) == null); // non-hex } test "artifact §4.7.16-p1: T card parses name, target, and value in order" { var buf: [256]u8 = undefined; const card = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " hi\n").?; const parsed = parseTCard(card, &buf).?; try std.testing.expectEqual(TSign.add, parsed.sign); try std.testing.expectEqualStrings("tag", parsed.name); try std.testing.expectEqualStrings(A_CARD_HASH40, parsed.target.?); try std.testing.expectEqualStrings("hi", parsed.value.?); } test "artifact §4.7.16-p2: T target '*' is a self-referential tag with a null target" { var buf: [256]u8 = undefined; const star = tokenizeCardLine("T +tag *\n").?; try std.testing.expect(parseTCard(star, &buf).?.target == null); const hash = tokenizeCardLine("T +tag " ++ A_CARD_HASH64 ++ "\n").?; try std.testing.expectEqualStrings(A_CARD_HASH64, parseTCard(hash, &buf).?.target.?); } test "artifact §4.7.16-r1: T card rejects a name not prefixed +/-/*" { var buf: [256]u8 = undefined; try std.testing.expect(parseTCard(tokenizeCardLine("T xtag " ++ A_CARD_HASH40 ++ "\n").?, &buf) == null); // Bare hash as the name: first decoded char is a hex digit, not a sign. const bare_hash_name = tokenizeCardLine("T " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseTCard(bare_hash_name, &buf) == null); } test "artifact §4.7.16-r2: T card rejects a remainder that is entirely hex digits" { var buf: [256]u8 = undefined; const bad = [_][]const u8{ "T +deadbeef " ++ A_CARD_HASH40 ++ "\n", "T +dead " ++ A_CARD_HASH40 ++ "\n", "T +DEAD " ++ A_CARD_HASH40 ++ "\n", // hexdig-ci: uppercase still all-hex }; for (bad) |line| { try std.testing.expect(parseTCard(tokenizeCardLine(line).?, &buf) == null); } // A non-hex byte in the remainder breaks the all-hex reject. const ok = tokenizeCardLine("T +dea-d " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseTCard(ok, &buf) != null); } test "artifact §4.7.16-r3: T card rejects a target failing '*' and the hash map" { var buf: [256]u8 = undefined; try std.testing.expect(parseTCard(tokenizeCardLine("T +tag xyz\n").?, &buf) == null); // A decoded '*' does not count -- target is never decoded (m3). try std.testing.expect(parseTCard(tokenizeCardLine("T +tag \\*\n").?, &buf) == null); } test "artifact §4.7.16-r4: T card rejects a fourth token" { var buf: [256]u8 = undefined; const four = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " val extra\n").?; try std.testing.expect(parseTCard(four, &buf) == null); } test "artifact §4.7.16-m1: an absent T value is null; a present empty value is not" { var buf: [256]u8 = undefined; const absent = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ "\n").?; try std.testing.expect(parseTCard(absent, &buf).?.value == null); const present_empty = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " \n").?; const parsed = parseTCard(present_empty, &buf).?; try std.testing.expect(parsed.value != null); try std.testing.expectEqualStrings("", parsed.value.?); } test "artifact §4.7.16-m2: the T value is escape-decoded" { var buf: [256]u8 = undefined; const card = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " a\\sb\n").?; try std.testing.expectEqualStrings("a b", parseTCard(card, &buf).?.value.?); } test "artifact §4.7.16-m3: the T target token is never decoded" { var buf: [256]u8 = undefined; // "\*" decodes to "*" but the raw wire token is 2 bytes, so it never // matches the literal self-referential target and fails the hash map. try std.testing.expect(parseTCard(tokenizeCardLine("T +tag \\*\n").?, &buf) == null); } test "artifact §4.7.16-m4: the T name is escape-decoded before the sign check" { var buf: [256]u8 = undefined; const cancel = tokenizeCardLine("T \\-tag " ++ A_CARD_HASH40 ++ "\n").?; const parsed_cancel = parseTCard(cancel, &buf).?; try std.testing.expectEqual(TSign.cancel, parsed_cancel.sign); try std.testing.expectEqualStrings("tag", parsed_cancel.name); const spaced = tokenizeCardLine("T +a\\sb " ++ A_CARD_HASH40 ++ "\n").?; const parsed_spaced = parseTCard(spaced, &buf).?; try std.testing.expectEqual(TSign.add, parsed_spaced.sign); try std.testing.expectEqualStrings("a b", parsed_spaced.name); } test "artifact §4.7.17-p1: U card decodes a present login" { var buf: [64]u8 = undefined; const plain = tokenizeCardLine("U alice\n").?; try std.testing.expectEqualStrings("alice", parseUCard(plain, &buf).?.login); const escaped = tokenizeCardLine("U a\\sb\n").?; try std.testing.expectEqualStrings("a b", parseUCard(escaped, &buf).?.login); } test "artifact §4.7.17-m1: a bare or empty U login substitutes 'anonymous'" { var buf: [64]u8 = undefined; const bare = tokenizeCardLine("U\n").?; try std.testing.expectEqualStrings("anonymous", parseUCard(bare, &buf).?.login); const empty = tokenizeCardLine("U \n").?; try std.testing.expectEqualStrings("anonymous", parseUCard(empty, &buf).?.login); } test "artifact §4.7.17-m2: an empty U token returns a card, unlike parseNCard's empty-token reject" { var buf: [64]u8 = undefined; const empty_u = tokenizeCardLine("U \n").?; try std.testing.expect(parseUCard(empty_u, &buf) != null); const empty_n = tokenizeCardLine("N \n").?; try std.testing.expect(parseNCard(empty_n, &buf) == null); } test "artifact §4.7.17-r1: U card rejects a second token" { var buf: [64]u8 = undefined; try std.testing.expect(parseUCard(tokenizeCardLine("U a b\n").?, &buf) == null); } test "artifact §4.7.18-p1/m1: body located by byte count after the size LF" { const artifact = "W 5\nhello\nX"; const w = parseWCard(artifact, 0).?; try std.testing.expectEqualStrings("hello", w.body); try std.testing.expectEqual(@as(usize, 10), w.next); } test "artifact §4.7.18-p2/w1: leading digits accumulated, suffix not re-checked (12x)" { const artifact = "W 12x\nabcdefghijkl\nT"; const w = parseWCard(artifact, 0).?; try std.testing.expectEqualStrings("abcdefghijkl", w.body); try std.testing.expectEqual(@as(usize, 12), w.body.len); } test "artifact §4.7.18-r1: size accumulator overflow is rejected" { const artifact = "W " ++ ("9" ** 25) ++ "\nbody\nX"; try std.testing.expect(parseWCard(artifact, 0) == null); } test "artifact §4.7.18-r2: declared size past remaining length is rejected" { const artifact = "W 100\nshort\n"; try std.testing.expect(parseWCard(artifact, 0) == null); } test "artifact §4.7.18-m1: body consumed by count, not scanned for a card boundary" { const artifact = "W 4\na\nbc\nX"; const w = parseWCard(artifact, 0).?; try std.testing.expectEqualStrings("a\nbc", w.body); try std.testing.expectEqual(@as(usize, 9), w.next); } test "artifact §4.7.18-r3: byte after declared-length body must be LF" { const artifact = "W 3\nabcXY"; try std.testing.expect(parseWCard(artifact, 0) == null); } test "artifact §4.7.18-r4: position one past the body LF must remain within the artifact" { const artifact = "W 0\n\n"; try std.testing.expect(parseWCard(artifact, 0) == null); } test "artifact §4.7.18-w2: no-leading-digit size parses as zero, accepted only if next byte is LF" { const accepted = "W abc\n\nX"; const w = parseWCard(accepted, 0).?; try std.testing.expectEqual(@as(usize, 0), w.body.len); try std.testing.expectEqual(@as(usize, 7), w.next); const rejected = "W abc\nZY"; try std.testing.expect(parseWCard(rejected, 0) == null); } test "artifact §4.7.18: W grammar guards -- missing SP or unterminated size line" { try std.testing.expect(parseWCard("W\n", 0) == null); // no SP try std.testing.expect(parseWCard("W 5", 0) == null); // no size-line LF before end } test "artifact §4.7.19-p1/m1: Z card accepts a 32-hex md5 token, raw and never decoded" { const card = tokenizeCardLine("Z " ++ MD5_32 ++ "\n").?; const z = parseZCard(card).?; try std.testing.expectEqualStrings(MD5_32, z.checksum); try std.testing.expectEqual(@as(usize, 32), z.checksum.len); } test "artifact §4.7.19-r1: Z/R checksum token must be exactly 32 hex" { try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..31] ++ "\n").?) == null); // 31 try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ A_CARD_HASH40 ++ "\n").?) == null); // 40, not 32 try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..31] ++ "g\n").?) == null); // non-hex try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32 ++ "\n").?) != null); // exactly 32 } test "artifact §4.7.19-r2: Z grammar takes a single token -- zero or two rejected" { try std.testing.expect(parseZCard(tokenizeCardLine("Z\n").?) == null); // zero tokens const second = tokenizeCardLine("Z " ++ MD5_32 ++ " " ++ MD5_32 ++ "\n").?; try std.testing.expect(parseZCard(second) == null); // second token } test "artifact §4.7.19-m1: Z card occupies the final 35 bytes of stripped content" { // z-card = "Z" SP md5-token LF: 1 + 1 + 32 + 1 == 35 (§4.4 l.742-743). try std.testing.expectEqual(@as(usize, 35), 1 + 1 + 32 + 1); // Ties to the §6.2 positional framing verifyZCard already implements: // marker at n-35, digest at n-33..n-1, terminating LF at n-1 untouched. // Empty-input MD5 digest (matches the self-checksum vector above) makes // this a genuine positional match, not just a length check. const stripped = "Z d41d8cd98f00b204e9800998ecf8427e\n"; try std.testing.expectEqual(@as(usize, 35), stripped.len); try std.testing.expectEqual(ZVerdict.match, verifyZCard(stripped)); } test "artifact §6.3.2-p1: R aggregate matches A.3 and empty-checkin vectors" { // A.2: zero tracked files -> MD5("") = the "initial empty baseline" R. var out: [32]u8 = undefined; try std.testing.expectEqualStrings( "d41d8cd98f00b204e9800998ecf8427e", aggregateChecksum(&.{}, &out), ); // A.3: three files, filename order, 85-byte input -> matches the R card. const files = [_]TrackedFile{ .{ .name = "alpha.txt", .content = "hello world\n" }, .{ .name = "beta.txt", .content = "fossil artifact format\n" }, .{ .name = "gamma.txt", .content = "R card test\n" }, }; try std.testing.expectEqualStrings( "95feb799536dc053e7968efb7cdd3ec3", aggregateChecksum(&files, &out), ); } test "artifact §6.3.2-m1: size header is minimal decimal (no leading zeros)" { // A.3 sizes 12/23 are already two-digit, so the p1 match above pins // minimal-decimal for those. A zero-byte file pins the size-0 case: the // record is "f" SP "0" LF with nothing after -- MD5("f 0\n"), a public // MD5 constant, not a leading-zero "f 00\n" or an omitted header. const files = [_]TrackedFile{.{ .name = "f", .content = "" }}; var out: [32]u8 = undefined; try std.testing.expectEqualStrings( "bad208e121af55e1d62088c7ed0699f9", aggregateChecksum(&files, &out), ); } test "artifact §6.3.2-m2: checksum covers name+size-header+content" { const files = [_]TrackedFile{ .{ .name = "alpha.txt", .content = "hello world\n" }, .{ .name = "beta.txt", .content = "fossil artifact format\n" }, .{ .name = "gamma.txt", .content = "R card test\n" }, }; var out: [32]u8 = undefined; const got = aggregateChecksum(&files, &out); try std.testing.expectEqualStrings("95feb799536dc053e7968efb7cdd3ec3", got); // Raw-content-only concatenation (no filename, no size header) is a // different, well-known MD5 value -- proving the header/name matter. var raw_digest: [16]u8 = undefined; std.crypto.hash.Md5.hash("hello world\nfossil artifact format\nR card test\n", &raw_digest, .{}); const raw_hex = std.fmt.bytesToHex(raw_digest, .lower); try std.testing.expect(!std.mem.eql(u8, got, &raw_hex)); } test "artifact §6.3.2-m3: filename order is the §4.5.3 comparator" { // The A.3 vector's order is itself the canonical order under the §4.5.3 // comparator -- assert that, then reuse it to reconfirm the p1 match. try std.testing.expectEqual(std.math.Order.lt, compareBytes("alpha.txt", "beta.txt")); try std.testing.expectEqual(std.math.Order.lt, compareBytes("beta.txt", "gamma.txt")); const files = [_]TrackedFile{ .{ .name = "alpha.txt", .content = "hello world\n" }, .{ .name = "beta.txt", .content = "fossil artifact format\n" }, .{ .name = "gamma.txt", .content = "R card test\n" }, }; var out: [32]u8 = undefined; try std.testing.expectEqualStrings("95feb799536dc053e7968efb7cdd3ec3", aggregateChecksum(&files, &out)); } test "artifact §6.2-p1: Z compute over first n-35 matches the A.2 vector" { // A.2 offset table: 92 bytes checksummed (0..91), marker@92, digest // 94..125, LF@126; n = 127. try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len); var out: [32]u8 = undefined; try std.testing.expectEqualStrings( "c62e3619f7c41ee77b33f00df8ec26f4", selfChecksum(a1_1_manifest[0..92], &out), ); try std.testing.expectEqual(ZVerdict.match, verifyZCard(a1_1_manifest)); } test "artifact §6.2-m2: verification never reads byte n-1" { // Mutate the artifact's terminating byte (n-1); the verdict is // unaffected because Z verification's compare range is n-33..n-2. var mutated: [127]u8 = undefined; @memcpy(&mutated, a1_1_manifest); try std.testing.expectEqual(@as(u8, '\n'), mutated[126]); mutated[126] = 'X'; try std.testing.expectEqual(ZVerdict.match, verifyZCard(&mutated)); } test "artifact §6.2-s1/-m1: absent/too-short Z is not a verification reject" { const table = [_][]const u8{ "C comment\n", // 10 bytes: no Z card at all "D 2007-07-21T14:09:59\n", // 22 bytes: still short of the 35-byte Z minimum }; for (table) |bytes| { try std.testing.expect(bytes.len < 35); try std.testing.expectEqual(ZVerdict.absent, verifyZCard(bytes)); // The absence itself never fails the framing gate here -- §5.2's // required-card rule is the one that rejects a missing Z. try std.testing.expect(frame(bytes) != null); } } test "artifact §6.1-p2/-r1: R/Z parse-accept is 32 hex, case-insensitive" { const md5_upper = "0123456789ABCDEF" ** 2; comptime std.debug.assert(md5_upper.len == 32); // Accept: all-lower and all-upper. try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32 ++ "\n").?) != null); try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ md5_upper ++ "\n").?) != null); try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32 ++ "\n").?) != null); try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ md5_upper ++ "\n").?) != null); // Reject: 31 chars, 33 chars, one non-hex byte, embedded space (splits // into two tokens, so arity rejects it). try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32[0..31] ++ "\n").?) == null); try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32 ++ "a\n").?) == null); try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32[0..16] ++ " " ++ MD5_32[16..32] ++ "\n").?) == null); try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..31] ++ "\n").?) == null); try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32 ++ "a\n").?) == null); try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..16] ++ " " ++ MD5_32[16..32] ++ "\n").?) == null); // One non-hex byte, built at runtime (can't ++ a mutated buffer). var bad_hex: [32]u8 = undefined; @memcpy(&bad_hex, MD5_32); bad_hex[0] = 'g'; var r_line: [35]u8 = undefined; r_line[0] = 'R'; r_line[1] = ' '; @memcpy(r_line[2..34], &bad_hex); r_line[34] = '\n'; try std.testing.expect(parseRCard(tokenizeCardLine(&r_line).?) == null); var z_line: [35]u8 = undefined; z_line[0] = 'Z'; z_line[1] = ' '; @memcpy(z_line[2..34], &bad_hex); z_line[34] = '\n'; try std.testing.expect(parseZCard(tokenizeCardLine(&z_line).?) == null); } test "artifact §6.1-m1: the parse path never case-folds the token" { const md5_upper = "0123456789ABCDEF" ** 2; comptime std.debug.assert(md5_upper.len == 32); const r = parseRCard(tokenizeCardLine("R " ++ md5_upper ++ "\n").?).?; try std.testing.expectEqualStrings(md5_upper, r.checksum); // verbatim, not lowered const z = parseZCard(tokenizeCardLine("Z " ++ md5_upper ++ "\n").?).?; try std.testing.expectEqualStrings(md5_upper, z.checksum); // verbatim, not lowered } fn parseNoLeak(allocator: std.mem.Allocator, bytes: []const u8) !void { if (parse(allocator, bytes)) |m| { var mm = m; mm.deinit(); // success owns the arena now -- release it, or the leak check misfires } else |err| switch (err) { ParseError.OutOfMemory => return err, else => {}, // SyntaxError + the 7 ClassifyError variants: not a leak signal } } test "artifact §4.2: parse propagates allocation failure (arena-per-parse)" { try std.testing.checkAllAllocationFailures(std.testing.allocator, parseNoLeak, .{a1_1_manifest}); } test "artifact fuzz: parse on arbitrary bytes never crashes (§4 robustness)" { const a = std.testing.allocator; var prng = std.Random.DefaultPrng.init(0xA47F_0001); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var buf: [512]u8 = undefined; const len = rand.uintLessThan(usize, buf.len + 1); for (buf[0..len]) |*b| b.* = rand.int(u8); // Contract: a declared ParseError or a Manifest value -- never a crash or UB. if (parse(a, buf[0..len])) |m| { var mm = m; mm.deinit(); } else |_| {} } } test "artifact fuzz: tokenizeCardLine + decodeEscapes never crash (§4.2/§4.3 robustness)" { var prng = std.Random.DefaultPrng.init(0xA47F_0002); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var buf: [128]u8 = undefined; // tokenizeCardLine's precondition: non-empty, no embedded LF, exactly // one trailing LF. const body_len = 1 + rand.uintLessThan(usize, buf.len - 1); for (buf[0 .. body_len - 1]) |*b| { b.* = rand.int(u8); if (b.* == '\n') b.* = ' '; } buf[body_len - 1] = '\n'; const line = buf[0..body_len]; if (tokenizeCardLine(line)) |card| { try std.testing.expect(isCardLetter(card.letter)); if (card.args) |args| { try std.testing.expect(args.len <= line.len); var out_buf: [128]u8 = undefined; const out = decodeEscapes(args, out_buf[0..args.len]); try std.testing.expect(out.len <= args.len); } // §4.7.15-4.7.19: the per-card parsers built on this batch's // tokenizer/decoder must also never crash on adversarial input, // regardless of card.letter (a rejecting null is fine). W is // excluded here -- it bypasses tokenizeCardLine entirely and // gets its own fuzz target below. var decode_buf: [128]u8 = undefined; const decode_bound = if (card.args) |a| a.len else 0; switch (card.letter) { 'R' => _ = parseRCard(card), 'T' => _ = parseTCard(card, decode_buf[0..decode_bound]), 'U' => _ = parseUCard(card, decode_buf[0..decode_bound]), 'Z' => _ = parseZCard(card), else => {}, } } } } test "artifact fuzz: parseWCard never crashes and honors its postconditions (§4.7.18 robustness)" { // W bypasses tokenizeCardLine (its body may embed LF), so it needs its // own target: random bytes with the 'W' letter forced at start. var prng = std.Random.DefaultPrng.init(0xA47F_0006); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var buf: [128]u8 = undefined; const len = 1 + rand.uintLessThan(usize, buf.len); // start < artifact.len for (buf[0..len]) |*b| b.* = rand.int(u8); buf[0] = 'W'; // precondition: artifact[start] == 'W' const artifact = buf[0..len]; // Contract: a null (rejection) or a WCard honoring next < len and // the terminator LF sitting one byte before next -- never a crash. if (parseWCard(artifact, 0)) |w| { try std.testing.expect(w.next < artifact.len); try std.testing.expect(w.next >= 1); try std.testing.expect(artifact[w.next - 1] == '\n'); try std.testing.expect(w.body.len <= artifact.len); } } } test "artifact fuzz: classifyTimestamp on arbitrary bytes never crashes (§4.6.4 robustness)" { var prng = std.Random.DefaultPrng.init(0xA47F_0003); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var buf: [64]u8 = undefined; const len = rand.uintLessThan(usize, buf.len + 1); for (buf[0..len]) |*b| b.* = if (rand.boolean()) rand.int(u8) else "0123456789-:.Tt"[rand.uintLessThan(usize, 15)]; _ = classifyTimestamp(buf[0..len]); } } test "artifact fuzz: framing trio never crash (§4.1/§4.1.1/§6.2 robustness)" { var prng = std.Random.DefaultPrng.init(0xA47F_0004); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var buf: [256]u8 = undefined; const len = rand.uintLessThan(usize, buf.len + 1); for (buf[0..len]) |*b| b.* = rand.int(u8); const bytes = buf[0..len]; // Contract: never grows the input (§4.1.1, §4.1), never crashes. const stripped = stripSignatureWrapper(bytes); try std.testing.expect(stripped.len <= bytes.len); if (frame(bytes)) |framed| try std.testing.expect(framed.len <= bytes.len); _ = verifyZCard(bytes); } } fn invertOrder(o: std.math.Order) std.math.Order { return switch (o) { .lt => .gt, .gt => .lt, .eq => .eq, }; } test "artifact fuzz: pathname/wiki-name/reserved/compare predicates never crash (§4.5.3/§4.6 robustness)" { var prng = std.Random.DefaultPrng.init(0xA47F_0005); const rand = prng.random(); var round: usize = 0; while (round < 400) : (round += 1) { var abuf: [64]u8 = undefined; var bbuf: [64]u8 = undefined; const alen = rand.uintLessThan(usize, abuf.len + 1); const blen = rand.uintLessThan(usize, bbuf.len + 1); for (abuf[0..alen]) |*b| b.* = rand.int(u8); for (bbuf[0..blen]) |*b| b.* = rand.int(u8); const a = abuf[0..alen]; const b = bbuf[0..blen]; _ = isSimplePathname(a); _ = isWikiName(a); _ = isReservedFilename(a); // compareBytes: reflexive, and swapping arguments exactly reverses // the order (§4.5.3). try std.testing.expectEqual(std.math.Order.eq, compareBytes(a, a)); try std.testing.expectEqual(compareBytes(a, b), invertOrder(compareBytes(b, a))); } } test "artifact §5.1: type codes and external names match Table 3" { const Row = struct { t: ArtifactType, code: u8, name: []const u8 }; const rows = [_]Row{ .{ .t = .manifest, .code = 1, .name = "checkin" }, .{ .t = .cluster, .code = 2, .name = "cluster" }, .{ .t = .control, .code = 3, .name = "tag" }, .{ .t = .wiki, .code = 4, .name = "wiki" }, .{ .t = .ticket, .code = 5, .name = "ticket" }, .{ .t = .attachment, .code = 6, .name = "attachment" }, .{ .t = .event, .code = 7, .name = "technote" }, .{ .t = .forum, .code = 8, .name = "forumpost" }, // draft-fossil-submanifests-00 §4.1: "| 9 | SUBMANIFEST | submanifest |". .{ .t = .submanifest, .code = 9, .name = "submanifest" }, }; for (rows) |row| { try std.testing.expectEqual(row.code, @intFromEnum(row.t)); try std.testing.expectEqualStrings(row.name, row.t.externalName()); } } /// `Census.letters` for a set of card letters: bit `n` is the letter `'A' + n`. /// A local twin of `artifact.maskOfLetters`, which is private to that module. fn censusMask(letters: []const u8) u32 { var m: u32 = 0; for (letters) |l| m |= @as(u32, 1) << @intCast(l - 'A'); return m; } test "artifact §5.3: each type-setting letter assigns its type" { // Each probe carries a D card alongside the letter under test. D never // sets a type, but its presence is what stops the submanifests §4.3 // reassignment from rewriting the MANIFEST-setting rows to SUBMANIFEST -- // so this stays a test of Table 5's assignment, not of the post-pass. const Row = struct { letter: u8, t: ArtifactType }; const rows = [_]Row{ .{ .letter = 'A', .t = .attachment }, .{ .letter = 'B', .t = .manifest }, .{ .letter = 'E', .t = .event }, .{ .letter = 'F', .t = .manifest }, .{ .letter = 'G', .t = .forum }, .{ .letter = 'H', .t = .forum }, .{ .letter = 'I', .t = .forum }, .{ .letter = 'J', .t = .ticket }, .{ .letter = 'K', .t = .ticket }, .{ .letter = 'L', .t = .wiki }, .{ .letter = 'M', .t = .cluster }, .{ .letter = 'Q', .t = .manifest }, .{ .letter = 'R', .t = .manifest }, // submanifests §4.3: "S joins B, F, Q, and R as a MANIFEST-setting // letter." .{ .letter = 'S', .t = .manifest }, }; for (rows) |row| { const c = Census{ .letters = censusMask(&.{ row.letter, 'D' }), }; try std.testing.expectEqual(row.t, determineType(c)); } } test "artifact fuzz: classify and determineType are total over arbitrary censuses (§5 robustness)" { var prng = std.Random.DefaultPrng.init(0xA47F_0007); const rand = prng.random(); // A fully arbitrary `letters` is a 32-bit word, so it practically always // carries a letter outside SUBMANIFEST's F-S-Z row and dies at §4.2 // legality before §4.4 is consulted. Every other round therefore draws // `letters` from that row alone, which is what carries an arbitrary census // far enough to exercise the sub-manifest semantic checks. const sm_alphabet = censusMask("FSZ"); var round: usize = 0; while (round < 400) : (round += 1) { const letters = rand.int(u32); const c = Census{ .letters = if (round % 2 == 0) letters else letters & sm_alphabet, .p_parents = rand.int(u8), .t_self_ref = rand.boolean(), .t_not_self_ref = rand.boolean(), .t_not_plus = rand.boolean(), .f_hashless = rand.boolean(), }; const t2 = determineType(c); try std.testing.expect(@intFromEnum(t2) >= 1 and @intFromEnum(t2) <= 9); // Contract: classify never panics -- always a declared ClassifyError // or a concrete code-1..9 type (§5.1-m1: never the sentinel). The // upper bound is 9 because an arbitrary census can set the S bit and // reach SUBMANIFEST through the submanifests §4.3 reassignment. if (classify(c)) |t| { try std.testing.expect(@intFromEnum(t) >= 1 and @intFromEnum(t) <= 9); } else |_| {} } } const TEST_TS = "2007-07-21T14:09:59"; // §4.6.4 writer form, reused across bodies const HASH40_HIGH: []const u8 = "f" ** 40; // sorts above A_CARD_HASH40 (§4.5.3) fn buildArtifact(buf: []u8, body: []const u8) []const u8 { std.debug.assert(buf.len >= body.len + 35); @memcpy(buf[0..body.len], body); var digest_buf: [32]u8 = undefined; const digest = selfChecksum(body, &digest_buf); buf[body.len] = 'Z'; buf[body.len + 1] = ' '; @memcpy(buf[body.len + 2 ..][0..32], digest); buf[body.len + 34] = '\n'; const total = body.len + 35; std.debug.assert(buf[total - 1] == '\n'); return buf[0..total]; } test "artifact §5: parse assembles the worked-example manifest" { var m = try parse(std.testing.allocator, a1_1_manifest); defer m.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m.kind); } test "artifact §4.4/§A.1.1: parse retains every decoded card of the worked example" { var m = try parse(std.testing.allocator, a1_1_manifest); defer m.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m.kind); // §4.3 Table 1: the wire's \s decodes to SP. try std.testing.expect(m.c != null); try std.testing.expectEqualStrings("initial empty baseline", m.c.?.comment); // §4.3.1: D is a raw, never-decoded position. try std.testing.expect(m.d != null); try std.testing.expectEqualStrings("2007-07-21T14:09:59", m.d.?.timestamp); // §4.7.13 m1: bare P (zero tokens) is retained, not dropped. try std.testing.expectEqual(@as(usize, 1), m.p.len); try std.testing.expectEqual(@as(usize, 0), m.p[0].count); try std.testing.expectEqual(@as(?[]const u8, null), m.p[0].parents); // §4.7.15: R is raw md5. try std.testing.expect(m.r != null); try std.testing.expectEqualStrings("d41d8cd98f00b204e9800998ecf8427e", m.r.?.checksum); // §4.7.17: U is decoded; no escapes present in this fixture. try std.testing.expect(m.u != null); try std.testing.expectEqualStrings("drh", m.u.?.login); // §4.7.19: Z is raw md5. try std.testing.expect(m.z != null); try std.testing.expectEqualStrings("c62e3619f7c41ee77b33f00df8ec26f4", m.z.?.checksum); // Every letter absent from this fixture retains as null/empty. try std.testing.expect(m.a == null); try std.testing.expect(m.b == null); try std.testing.expect(m.e == null); try std.testing.expect(m.g == null); try std.testing.expect(m.h == null); try std.testing.expect(m.i == null); try std.testing.expect(m.k == null); try std.testing.expect(m.l == null); try std.testing.expect(m.n == null); try std.testing.expect(m.w == null); try std.testing.expectEqual(@as(usize, 0), m.f.len); try std.testing.expectEqual(@as(usize, 0), m.j.len); try std.testing.expectEqual(@as(usize, 0), m.m.len); try std.testing.expectEqual(@as(usize, 0), m.q.len); try std.testing.expectEqual(@as(usize, 0), m.t.len); } test "artifact §4.7.13: a P card with parents is retained with count and raw slice" { var buf: [220]u8 = undefined; const body = "D " ++ TEST_TS ++ "\nP " ++ A_CARD_HASH40 ++ " " ++ HASH40_HIGH ++ "\n"; const bytes = buildArtifact(buf[0..], body); var m = try parse(std.testing.allocator, bytes); defer m.deinit(); try std.testing.expectEqual(@as(usize, 1), m.p.len); try std.testing.expectEqual(@as(usize, 2), m.p[0].count); var it = m.p[0].hashes(); // §4.7.13: tokens are neither sorted nor deduped -- order is semantic try std.testing.expectEqualStrings(A_CARD_HASH40, it.next().?); try std.testing.expectEqualStrings(HASH40_HIGH, it.next().?); try std.testing.expect(it.next() == null); } test "artifact §4.7.3: a B card is retained as the delta-manifest marker" { var buf: [128]u8 = undefined; const bytes = buildArtifact(buf[0..], "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n"); var m = try parse(std.testing.allocator, bytes); defer m.deinit(); try std.testing.expect(m.b != null); try std.testing.expectEqualStrings(A_CARD_HASH40, m.b.?.hash); } test "artifact §5.2/§5.4: parse accepts a minimal well-formed artifact of each type" { const Row = struct { body: []const u8, t: ArtifactType }; const rows = [_]Row{ .{ .body = "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n", .t = .manifest }, .{ .body = "M " ++ A_CARD_HASH40 ++ "\n", .t = .cluster }, .{ .body = "D " ++ TEST_TS ++ "\nT +tagname " ++ A_CARD_HASH40 ++ "\nU drh\n", .t = .control }, .{ .body = "D " ++ TEST_TS ++ "\nL Wiki\\sPage\nU drh\nW 5\nhello\n", .t = .wiki }, .{ .body = "D " ++ TEST_TS ++ "\nJ field val\nK " ++ A_CARD_HASH40 ++ "\nU drh\n", .t = .ticket }, .{ .body = "A file.txt " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n", .t = .attachment }, .{ .body = "D " ++ TEST_TS ++ "\nE " ++ TEST_TS ++ " " ++ A_CARD_HASH40 ++ "\nW 5\nhello\n", .t = .event }, .{ .body = "D " ++ TEST_TS ++ "\nG " ++ A_CARD_HASH40 ++ "\nU drh\nW 5\nhello\n", .t = .forum }, }; var buf: [220]u8 = undefined; for (rows) |row| { const bytes = buildArtifact(buf[0..], row.body); var m = try parse(std.testing.allocator, bytes); defer m.deinit(); try std.testing.expectEqual(row.t, m.kind); } } test "artifact §5.5: an input whose first card letter is N..Z is rejected" { // Every type's smallest required letter is <= 'M' (CLUSTER's own M) -- // an 'N' first card can satisfy no type's required set (§5.5 minima). try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "N text/plain\n")); } test "artifact §5.5: a first card letter of M (cluster minimum) passes the pre-filter" { var buf: [80]u8 = undefined; const bytes = buildArtifact(buf[0..], "M " ++ A_CARD_HASH40 ++ "\n"); var m = try parse(std.testing.allocator, bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.cluster, m.kind); } test "artifact §4.1: raw input not ending in LF is rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS)); } test "artifact §4.1/§6.2: a present-but-mismatched Z card is rejected" { const body = "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n"; var buf: [128]u8 = undefined; const good = buildArtifact(buf[0..], body); var mutated: [128]u8 = undefined; @memcpy(mutated[0..good.len], good); const digest_pos = body.len + 2; // first hex digit of the digest mutated[digest_pos] = if (mutated[digest_pos] == 'a') 'b' else 'a'; try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, mutated[0..good.len])); } test "artifact §4.2.2: a card line with an undefined/out-of-alphabet letter is rejected" { // 'O' as the SECOND card (not the first) proves the tokenizer's own // §4.2.2 rejection fires -- a first-card 'O' would instead be caught // earlier by the §5.5 pre-filter ('O' > 'M'). try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\nO foo\n")); } test "artifact §4.2.1: a card carrying excess tokens is rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "B " ++ A_CARD_HASH40 ++ " extra\n")); } test "artifact §4.4: out-of-order cards are rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\nC hello\n")); } test "artifact §4.5.1: a duplicate single-occurrence card is rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\nD 2008-01-01T00:00:00\n")); } test "artifact §4.5.2: a descending F-card run is rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "F beta.txt\nF alpha.txt\n")); } test "artifact §4.5.2: a descending J-card run is rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "J field2 val\nJ field1 val\n")); } test "artifact §4.5.2: a descending M-card run is rejected" { const bytes = "M " ++ HASH40_HIGH ++ "\nM " ++ A_CARD_HASH40 ++ "\n"; try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); } test "artifact §4.5.2: a T-card run not two-level ascending is rejected" { // A leading D card keeps the run past the §5.5 pre-filter ('T' > 'M') // so the rejection under test is the intra-run check, not the filter. const bytes = "D " ++ TEST_TS ++ "\nT +tagname " ++ HASH40_HIGH ++ "\nT +tagname " ++ A_CARD_HASH40 ++ "\n"; try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); } test "artifact §4.7.16: the T intra-run key is the full name token including its sign" { // Lines 1373-1376: "the name used as the primary ordering key is the full // name token including its leading sign character (+, -, or *), not the // sign-stripped tag name." The wire signs sort * (0x2A) < + (0x2B) < // - (0x2D). Both vectors below are chosen so full-token order and // sign-stripped order DISAGREE — each exercises the fix, rejecting the // pre-fix sign-blind reading. // // Accept: "*sym-x" then "+apple". Full-token order ascends (* 0x2A < + 0x2B), // so §4.7.16 accepts it — but the sign-STRIPPED names descend ("sym-x" > // "apple"), so a sign-blind reader would WRONGLY reject this legal run. // A B+D manifest carries the self-referential run (a check-in's own tags). var buf: [256]u8 = undefined; const accepted = buildArtifact(buf[0..], "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\nT *sym-x *\nT +apple *\n"); var m = try parse(std.testing.allocator, accepted); defer m.deinit(); try std.testing.expectEqual(@as(usize, 2), m.t.len); try std.testing.expectEqual(af.TSign.propagate, m.t[0].sign); try std.testing.expectEqualStrings("sym-x", m.t[0].name); try std.testing.expectEqual(af.TSign.add, m.t[1].sign); try std.testing.expectEqualStrings("apple", m.t[1].name); // Reject: "+apple" then "*sym-x" — the mirror. Full-token order descends // (+ 0x2B > * 0x2A), so §4.7.16 rejects it — yet the sign-stripped names // ascend ("apple" < "sym-x"), so a sign-blind reader would WRONGLY accept // it. A leading D card keeps the run past the §5.5 pre-filter ('T' > 'M'). const rejected = "D " ++ TEST_TS ++ "\nT +apple *\nT *sym-x *\n"; try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, rejected)); } test "artifact §4.7.16: within one sign the stripped name is the secondary key" { // §4.7.16: "Intra-run ordering is the two-level rule of Section 4.5.2." // When the leading sign bytes tie, the sign-stripped name must break the // tie — a run whose signs are equal but whose names descend is rejected. // This proves the sign is the PRIMARY key, not the ONLY key. (Signs equal, // so this vector alone does not distinguish the full-token fix from the // pre-fix reading — the test above does; this pins the secondary key.) const bytes = "D " ++ TEST_TS ++ "\nT *sym-zebra *\nT *sym-apple *\n"; try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); } test "artifact §4.7.16: a T run ascending by the full sign-included name is accepted" { // §4.7.16 lines 1373-1376: the primary ordering key is the full name token // INCLUDING its leading sign, so `*`(0x2a) sorts before `-`(0x2d) even though // "sym-clear-title" < "sym-trunk" by the sign-stripped name. Fossil emits this // exact propagate-before-cancel branch-switch run; it MUST parse. var buf: [256]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nT *sym-trunk " ++ A_CARD_HASH40 ++ "\nT -sym-clear-title " ++ HASH40_HIGH ++ "\nU drh\n"); var m = try parse(std.testing.allocator, bytes); defer m.deinit(); } test "artifact §4.7.16: a T run ascending by stripped name but descending by full token is rejected" { // Negative space for the full-token rule: stripped "sym-a" < "sym-b" would pass // the old sign-stripped check, but the full tokens "-sym-a"(0x2d) > "*sym-b"(0x2a) // descend, so §4.5.2's strict ascent MUST still reject the run. var buf: [256]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nT -sym-a " ++ A_CARD_HASH40 ++ "\nT *sym-b " ++ HASH40_HIGH ++ "\nU drh\n"); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); } test "artifact §4.7.16: the full-token ordering key sorts * < + < - by sign byte" { // §4.7.16 + §4.5.2 Table 2: with the sign byte leading the key, one tag name // orders propagate < add < cancel because `*`(0x2a) < `+`(0x2b) < `-`(0x2d). try std.testing.expectEqual(std.math.Order.lt, compareTag("*sym", "*", "+sym", "*")); try std.testing.expectEqual(std.math.Order.lt, compareTag("+sym", "*", "-sym", "*")); try std.testing.expectEqual(std.math.Order.lt, compareTag("*sym", "*", "-sym", "*")); try std.testing.expectEqual(std.math.Order.gt, compareTag("-sym", "*", "*sym", "*")); } test "artifact §4.7: a card whose own grammar fails is rejected" { try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D not-a-timestamp\n")); } test "artifact §4.1: trailing bytes after the final card are rejected" { const body = "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n"; var buf: [128]u8 = undefined; const good = buildArtifact(buf[0..], body); var full: [160]u8 = undefined; @memcpy(full[0..good.len], good); const junk = "X garbage\n"; @memcpy(full[good.len..][0..junk.len], junk); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, full[0 .. good.len + junk.len])); } test "artifact §5.2: a card outside the finalized type's allowed set is rejected" { var buf: [128]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nM " ++ A_CARD_HASH40 ++ "\n"); try std.testing.expectError(error.DisallowedCard, parse(std.testing.allocator, bytes)); } test "artifact §5.4: CONTROL with a self-referential T card is rejected" { var buf: [220]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nT +tagname *\nU drh\n"); try std.testing.expectError(error.ControlSelfTag, parse(std.testing.allocator, bytes)); } test "artifact §5.4: EVENT T card not self-referential is rejected" { var buf: [220]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nE " ++ TEST_TS ++ " " ++ A_CARD_HASH40 ++ "\nT +tagname " ++ A_CARD_HASH40 ++ "\nW 5\nhello\n"); try std.testing.expectError(error.EventTagNotSelfRef, parse(std.testing.allocator, bytes)); } test "artifact §5.4: EVENT T card lacking the + prefix is rejected" { var buf: [220]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nE " ++ TEST_TS ++ " " ++ A_CARD_HASH40 ++ "\nT -tagname *\nW 5\nhello\n"); try std.testing.expectError(error.EventTagNotPlus, parse(std.testing.allocator, bytes)); } test "artifact §5.4: FORUM with both H and I cards is rejected" { var buf: [220]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nH My\\sThread\nI " ++ A_CARD_HASH40 ++ "\nU drh\nW 5\nhello\n"); try std.testing.expectError(error.ForumHeadAndReply, parse(std.testing.allocator, bytes)); } test "artifact §5.4: FORUM P card with more than one parent is rejected" { var buf: [220]u8 = undefined; const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nG " ++ A_CARD_HASH40 ++ "\nP " ++ A_CARD_HASH40 ++ " " ++ HASH40_HIGH ++ "\nU drh\nW 5\nhello\n"); try std.testing.expectError(error.ForumMultiParent, parse(std.testing.allocator, bytes)); } test "artifact §7.1: policy value strings match case-sensitively and exactly" { // §7.1-p1: "Value strings MUST be matched case-sensitively and exactly." try std.testing.expectEqual(HashPolicy.sha1, policyFromString("sha1").?); try std.testing.expectEqual(HashPolicy.auto, policyFromString("auto").?); try std.testing.expectEqual(HashPolicy.sha3, policyFromString("sha3").?); try std.testing.expectEqual(HashPolicy.sha3_only, policyFromString("sha3-only").?); try std.testing.expectEqual(HashPolicy.shun_sha1, policyFromString("shun-sha1").?); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("SHA1")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("Auto")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("SHA3-ONLY")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("Sha3-Only")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString(" sha1")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("sha1 ")); } test "artifact §7.1: unrecognized policy value is rejected" { // §7.1-r1: "an unrecognized value is a fatal error" -- modeled as null. try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("md5")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("sha")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("sha3only")); try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("auto\n")); } test "artifact §7.1: policy ordinals are 0..4 in table order" { // §7.1-w1: "The ordinal column gives each policy's stable numeric // encoding (0 through 4, in the order listed)." try std.testing.expectEqual(@as(u3, 0), @intFromEnum(HashPolicy.sha1)); try std.testing.expectEqual(@as(u3, 1), @intFromEnum(HashPolicy.auto)); try std.testing.expectEqual(@as(u3, 2), @intFromEnum(HashPolicy.sha3)); try std.testing.expectEqual(@as(u3, 3), @intFromEnum(HashPolicy.sha3_only)); try std.testing.expectEqual(@as(u3, 4), @intFromEnum(HashPolicy.shun_sha1)); } test "artifact §7.1: policy string round-trips" { // Guards the single value-string table: encode then parse is identity // for every policy (supports p1's encode/parse pairing). for (std.enums.values(HashPolicy)) |p| { try std.testing.expectEqual(p, policyFromString(policyName(p)).?); } } test "artifact §7.1: sha1 and auto name with the SHA-1 primary hash" { // "Under sha1 and auto the primary hash MUST be SHA-1." try std.testing.expectEqual(HashAlgo.sha1, primaryHash(.sha1)); try std.testing.expectEqual(HashAlgo.sha1, primaryHash(.auto)); } test "artifact §7.1: sha3, sha3-only, shun-sha1 name with the SHA3-256 primary" { // "under sha3, sha3-only, and shun-sha1 it MUST be SHA3-256." try std.testing.expectEqual(HashAlgo.sha3_256, primaryHash(.sha3)); try std.testing.expectEqual(HashAlgo.sha3_256, primaryHash(.sha3_only)); try std.testing.expectEqual(HashAlgo.sha3_256, primaryHash(.shun_sha1)); } test "artifact §7.1: alternative hash permitted only under sha1, auto, sha3" { // "permitted only under sha1 and auto (where it is SHA3-256) and under // sha3 (where it is SHA-1)." try std.testing.expectEqual(HashAlgo.sha3_256, alternativeHash(.sha1).?); try std.testing.expectEqual(HashAlgo.sha3_256, alternativeHash(.auto).?); try std.testing.expectEqual(HashAlgo.sha1, alternativeHash(.sha3).?); } test "artifact §7.1: alternative hash request fails under sha3-only and shun-sha1" { // "a request for the alternative hash MUST fail, yielding a failure // indication and an empty result rather than a hash value." try std.testing.expectEqual(@as(?HashAlgo, null), alternativeHash(.sha3_only)); try std.testing.expectEqual(@as(?HashAlgo, null), alternativeHash(.shun_sha1)); } test "artifact §7.2: default is sha3 when a SHA3 name exists or no SHA-1 name exists" { // "If the repository already contains any SHA3-named artifact ... or // contains no SHA-1-named artifact ... at all -- including the case of // an empty repository -- the default is sha3." try std.testing.expectEqual(HashPolicy.sha3, defaultPolicy(false, false)); // empty repo try std.testing.expectEqual(HashPolicy.sha3, defaultPolicy(false, true)); try std.testing.expectEqual(HashPolicy.sha3, defaultPolicy(true, true)); } test "artifact §7.2: default is auto only with a SHA-1 name and no SHA3 name" { // "Otherwise -- only when at least one SHA-1 artifact exists and zero // SHA3 artifacts exist -- the default is auto." try std.testing.expectEqual(HashPolicy.auto, defaultPolicy(true, false)); } test "artifact §7.2: default derivation never yields shun-sha1" { // "This derivation never yields shun-sha1." const bools = [_]bool{ false, true }; for (bools) |has_sha1| { for (bools) |has_sha3| { const p = defaultPolicy(has_sha1, has_sha3); try std.testing.expect(p == .sha3 or p == .auto); } } } test "artifact §7.3: setting the policy back to sha1 is never coerced" { // "Nothing prevents an operator from explicitly setting the policy // back to sha1" -- and more generally, no non-auto request is coerced. try std.testing.expectEqual(HashPolicy.sha1, coerceAssignment(.sha1, true)); try std.testing.expectEqual(HashPolicy.sha1, coerceAssignment(.sha1, false)); try std.testing.expectEqual(HashPolicy.sha3, coerceAssignment(.sha3, true)); try std.testing.expectEqual(HashPolicy.sha3_only, coerceAssignment(.sha3_only, true)); try std.testing.expectEqual(HashPolicy.shun_sha1, coerceAssignment(.shun_sha1, true)); } test "artifact §7.3: requesting auto with a SHA3 artifact present stores sha3" { // "Requesting auto when the repository already holds a SHA3-named // artifact silently stores sha3 instead." try std.testing.expectEqual(HashPolicy.sha3, coerceAssignment(.auto, true)); try std.testing.expectEqual(HashPolicy.auto, coerceAssignment(.auto, false)); } test "artifact §7.4: auto ratchets to sha3 when the selected name exceeds 40 characters" { // "MUST promote the repository to sha3 ... whenever that selected name // exceeds 40 characters." try std.testing.expectEqual(HashPolicy.sha3, ratchet(.auto, sha3_name_64)); } test "artifact §7.4: the ratchet applies only under auto and only above 40 chars" { // A fresh SHA-1 name under auto is exactly 40 characters -- no // ratchet; and the automatic forward ratchet applies only while the // effective policy is auto (§7.3's "no demotion guard" sentence). try std.testing.expectEqual(HashPolicy.auto, ratchet(.auto, a1_1_name)); try std.testing.expectEqual(HashPolicy.sha1, ratchet(.sha1, sha3_name_64)); try std.testing.expectEqual(HashPolicy.sha3, ratchet(.sha3, sha3_name_64)); try std.testing.expectEqual(HashPolicy.sha3_only, ratchet(.sha3_only, sha3_name_64)); try std.testing.expectEqual(HashPolicy.shun_sha1, ratchet(.shun_sha1, sha3_name_64)); } test "artifact §7.4: a caller-supplied 64-hex SHA3-256 name triggers the ratchet" { // §7.4 trigger 1: "a caller-supplied 64-hexadecimal SHA3-256 name // accepted for storage during transfer or ingest." try std.testing.expectEqual(HashPolicy.sha3, ratchet(.auto, sha3_name_64)); } test "artifact §7.5: naming computes the alternative hash first, falls back to primary on a miss" { // "MUST first compute the alternative-algorithm hash (where the // policy permits one) and look it up; only if that lookup misses does // it fall back to naming the content with the primary hash." try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha1, true)); try std.testing.expectEqual(HashAlgo.sha1, dedupNameAlgo(.sha1, false)); try std.testing.expectEqual(HashAlgo.sha1, dedupNameAlgo(.sha3, true)); try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha3, false)); try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.auto, true)); try std.testing.expectEqual(HashAlgo.sha1, dedupNameAlgo(.auto, false)); } test "artifact §7.5: under sha3-only and shun-sha1 new content is named SHA3-256 even when a SHA-1 name exists" { // "Because these policies disallow the alternative hash, the pre-check // cannot discover an existing SHA-1 name for the same bytes. New // artifacts MUST be named with SHA3-256 even when identical content // already carries a SHA-1 name." try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha3_only, true)); try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha3_only, false)); try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.shun_sha1, true)); try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.shun_sha1, false)); } const d8_h1: []const u8 = A_CARD_HASH40; const d8_h2: []const u8 = A_CARD_HASH64[0..40]; comptime { std.debug.assert(d8_h1.len == 40 and d8_h2.len == 40); std.debug.assert(!std.mem.eql(u8, d8_h1, d8_h2)); } fn d8F(filename: []const u8, hash: ?[]const u8) FCard { return .{ .filename = filename, .hash = hash, .perm = null, .old_name = null, .mode = .regular }; } fn d8ExpectFCard(expected: FCard, actual: FCard) !void { try std.testing.expectEqualStrings(expected.filename, actual.filename); try std.testing.expectEqual(expected.hash == null, actual.hash == null); if (expected.hash) |h| try std.testing.expectEqualStrings(h, actual.hash.?); } fn d8ExpectStream(walk: *MergeWalk, expected: []const FCard) !void { for (expected) |exp| { const got = walk.next() orelse return error.TestUnexpectedResult; try d8ExpectFCard(exp, got); } try std.testing.expect(walk.next() == null); } const D8SpyResolver = struct { result: ?ManifestFiles, calls: usize = 0, fn resolveImpl(ctx: *anyopaque, hash: []const u8) ?ManifestFiles { _ = hash; const self: *D8SpyResolver = @ptrCast(@alignCast(ctx)); self.calls += 1; return self.result; } fn resolver(self: *D8SpyResolver) BaselineResolver { return .{ .ctx = self, .resolveFn = resolveImpl }; } }; test "artifact §8: manifest is a delta iff it carries a B card" { // "A manifest is a delta manifest if and only if it contains a B card; // otherwise it is a baseline manifest." (§8-p1) const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &.{} }; const baseline = ManifestFiles{ .baseline_hash = null, .files = &.{} }; try std.testing.expect(isDelta(delta)); try std.testing.expect(!isDelta(baseline)); } test "artifact §8: full file set is derived by merging delta with baseline" { // "Consumers that need the full file set MUST derive it by merging the // delta's F cards with the baseline's, as specified in this section." // (§8-m1) const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("b", d8_h2)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2) }); } test "artifact §8.1: merge walk relies on sorted file lists" { // "The merge walk requires both file lists sorted by decoded filename // under the canonical comparator (Section 4.5.3). That sortedness is a // parse-time invariant (Section 4.5.2): every successfully parsed // manifest satisfies it." (§8.1-p1) const baseline_files = [_]FCard{ d8F("a", d8_h1), d8F("c", d8_h1) }; const delta_files = [_]FCard{d8F("b", d8_h2)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2), d8F("c", d8_h1) }); } test "artifact §8.1: hashless F card is a delete marker removing the baseline file" { // "A delete marker is an F card that carries no hash. It is permitted // only in a delta manifest -- in a baseline manifest every F card MUST // carry a hash (Section 4.7.6) -- and it carries a null hash, // interpreted by the merge walk as removal of the named file from the // baseline's set." (§8.1-r1) const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("a", null)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); var set: [1]FCard = undefined; var n: usize = 0; while (walk.next()) |f| { if (f.hash == null) continue; std.debug.assert(n < set.len); set[n] = f; n += 1; } try std.testing.expectEqual(@as(usize, 0), n); // "a" removed from the set } test "artifact §8.2: merge-walk over two cursors (comprehensive)" { // "To enumerate the complete file set of a delta manifest, an // implementation MUST perform a filename-ordered merge-walk over two // cursors: one over the baseline's file list and one over the delta's. // Filenames are compared byte-wise on their decoded form (Section // 4.5.3)." (§8.2-p1) const baseline_files = [_]FCard{ d8F("a", d8_h1), d8F("b", d8_h1), d8F("d", d8_h1) }; const delta_files = [_]FCard{ d8F("b", d8_h2), d8F("c", null), d8F("d", null) }; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2), d8F("c", null) }); var walk2 = try enumerate(delta, spy.resolver()); var set: [3]FCard = undefined; var n: usize = 0; while (walk2.next()) |f| { if (f.hash == null) continue; std.debug.assert(n < set.len); set[n] = f; n += 1; } try std.testing.expectEqual(@as(usize, 2), n); try d8ExpectFCard(d8F("a", d8_h1), set[0]); try d8ExpectFCard(d8F("b", d8_h2), set[1]); } test "sparse-cone §4.1: a coned merge-walk equals the dense walk filtered" { // Goal: the cone field on MergeWalk yields exactly the entries the dense // walk yields whose filenames the cone covers -- including across the // delta-dialect merge, where a covered baseline entry, a covered delta // replacement, and covered/uncovered delete markers all interleave. // Method: run the same delta enumeration twice, dense and coned, and // compare the coned stream against the hand-filtered dense stream. const gpa = std.testing.allocator; const cone_mod = @import("cone.zig"); const baseline_files = [_]FCard{ d8F("docs/a", d8_h1), d8F("src/a", d8_h1), d8F("src/b", d8_h1), d8F("tools/x", d8_h1), }; const delta_files = [_]FCard{ d8F("src/b", d8_h2), d8F("src/c", null), d8F("tools/x", null), }; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var cone = try cone_mod.Cone.init(gpa, &.{"src"}, &.{}); defer cone.deinit(gpa); // Dense reference stream, filtered by hand. var dense = try enumerate(delta, spy.resolver()); var expected: [8]FCard = undefined; var expected_n: usize = 0; while (dense.next()) |f| { if (!cone.covers(f.filename)) continue; std.debug.assert(expected_n < expected.len); expected[expected_n] = f; expected_n += 1; } // Positive space: the cone admits src/a, src/b (replaced), src/c (marker). try std.testing.expectEqual(@as(usize, 3), expected_n); // Coned stream. var coned = try enumerate(delta, spy.resolver()); coned.sparse = &cone; var coned_n: usize = 0; while (coned.next()) |f| { std.debug.assert(coned_n < expected_n); // negative space: never MORE entries try d8ExpectFCard(expected[coned_n], f); coned_n += 1; } try std.testing.expectEqual(expected_n, coned_n); } test "artifact §8.2: same name, delta carries hash, delta replaces" { // RFC step 1: "If both cursors name the same file and the delta entry // carries a hash, the delta entry replaces the baseline entry: both // cursors advance and the delta entry is emitted." (§8.2-r1) const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("a", d8_h2)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{d8F("a", d8_h2)}); } test "artifact §8.2: same name, delta delete marker removes the file" { // RFC step 2: "If both cursors name the same file and the delta entry // carries no hash (a delete marker), the file is removed: both cursors // advance and no entry is emitted." (§8.2-r2) const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("a", null)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{}); } test "artifact §8.2: differing names, sorts-first entry emitted" { // RFC step 3: "If the names differ, the entry that sorts first is // emitted and only its cursor advances." (§8.2-r3) { // (a) delta's "a" sorts before baseline's "b" const baseline_files = [_]FCard{d8F("b", d8_h1)}; const delta_files = [_]FCard{d8F("a", d8_h2)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h2), d8F("b", d8_h1) }); } { // (b) baseline's "a" sorts before delta's "b" const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("b", d8_h2)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2) }); } } test "artifact §8.2: unmatched delete marker emitted but adds no file" { // "A delta delete marker whose name is not present in the baseline (an // unmatched deletion) is emitted here like any other sorts-first entry, // but because it carries no hash it adds no file to the reconstructed // set. An unmatched deletion is therefore a no-op for materialization, // not an error." (§8.2-w1) const baseline_files = [_]FCard{d8F("b", d8_h1)}; const delta_files = [_]FCard{d8F("a", null)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", null), d8F("b", d8_h1) }); var walk2 = try enumerate(delta, spy.resolver()); var set: [2]FCard = undefined; var n: usize = 0; while (walk2.next()) |f| { if (f.hash == null) continue; std.debug.assert(n < set.len); set[n] = f; n += 1; } try std.testing.expectEqual(@as(usize, 1), n); try d8ExpectFCard(d8F("b", d8_h1), set[0]); } test "artifact §8.2: enumeration terminates only when both cursors exhausted (tail delete markers)" { // "Enumeration terminates only when both cursors are exhausted. Once // either list runs out, the remaining entries of the other list are // emitted verbatim -- including any delete markers remaining in the // delta's tail." (§8.2-m1) { // delta tail, including a trailing delete marker const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("z", null)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("z", null) }); } { // baseline tail, delta exhausted first const baseline_files = [_]FCard{ d8F("a", d8_h1), d8F("b", d8_h1) }; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &.{} }; var walk = try enumerate(delta, spy.resolver()); try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h1) }); } } test "artifact §8.2: consumers filter hash-absent entries" { // "Consumers of the merged stream MUST therefore filter out entries // whose hash is absent when computing derived results, as the // aggregate-checksum computation over the merged file set does // (Section 6.3.3)." (§8.2-m2) const baseline_files = [_]FCard{d8F("a", d8_h1)}; const delta_files = [_]FCard{d8F("z", null)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; var walk = try enumerate(delta, spy.resolver()); var tracked: [1]TrackedFile = undefined; var n: usize = 0; while (walk.next()) |f| { if (f.hash == null) continue; // m2: filter the hash-absent marker std.debug.assert(n < tracked.len); tracked[n] = .{ .name = f.filename, .content = "" }; n += 1; } try std.testing.expectEqual(@as(usize, 1), n); // marker excluded, not fed forward var out: [32]u8 = undefined; _ = aggregateChecksum(tracked[0..n], &out); // §6.3.3: no crash on the filtered set } test "artifact §8.3: baseline resolved lazily, only when needed" { // "The baseline identified by the B card hash is resolved lazily: it // is located and parsed as a manifest artifact the first time the // baseline is needed." (§8.3-p1) const baseline_files = [_]FCard{d8F("a", d8_h1)}; var delta_spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &.{} }; _ = try enumerate(delta, delta_spy.resolver()); try std.testing.expectEqual(@as(usize, 1), delta_spy.calls); var baseline_spy = D8SpyResolver{ .result = null }; const baseline_manifest = ManifestFiles{ .baseline_hash = null, .files = &baseline_files }; _ = try enumerate(baseline_manifest, baseline_spy.resolver()); try std.testing.expectEqual(@as(usize, 0), baseline_spy.calls); } test "artifact §8.3: unresolvable baseline is a fatal error of the enumeration" { // "Hard failure, for the file-enumeration entry points: beginning full // file-set enumeration, and single-name search (Section 8.4), MUST // treat an unresolvable baseline as a fatal error of the enumeration." // (§8.3-m1, both entry points) var spy = D8SpyResolver{ .result = null }; const delta_files = [_]FCard{d8F("b", d8_h2)}; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; try std.testing.expectError(error.BaselineUnresolvable, enumerate(delta, spy.resolver())); try std.testing.expectError(error.BaselineUnresolvable, lookup(delta, "a", spy.resolver())); } test "artifact §8.3: baseline resolved before iteration, no silent delta-only degrade" { // "Per-step enumeration reports no error for an unresolved baseline... // Implementations MUST therefore ensure the baseline is resolved, // under the hard-failure discipline, before iteration begins." // (§8.3-m2) -- `enumerate` returns the error itself (not an ok // `MergeWalk` that would silently yield only the delta's own entries), // and the resolver was actually consulted before that error surfaced. var spy = D8SpyResolver{ .result = null }; const delta_files = [_]FCard{d8F("b", d8_h2)}; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; try std.testing.expectError(error.BaselineUnresolvable, enumerate(delta, spy.resolver())); try std.testing.expectEqual(@as(usize, 1), spy.calls); // resolution attempted, not skipped } test "artifact §4.7.3/§8.3: resolved baseline that is itself a delta manifest is rejected" { // "If the resolved baseline is itself a delta manifest (it carries a B // card), the delta manifest naming it is invalid and MUST be // rejected." (§4.7.3, enforced at §8's resolution point, §8.3-m1) const cases = [_][]const FCard{ &.{d8F("a", null)}, // (a) delta baseline carries a delete marker &.{d8F("a", d8_h1)}, // (b) delta baseline carries only hashed cards }; for (cases) |case_files| { var spy = D8SpyResolver{ .result = .{ .baseline_hash = d8_h2, .files = case_files } }; const delta_files = [_]FCard{d8F("b", d8_h2)}; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; try std.testing.expectError(error.BaselineIsDelta, enumerate(delta, spy.resolver())); try std.testing.expectError(error.BaselineIsDelta, lookup(delta, "a", spy.resolver())); } } test "artifact §8.4: delta's own F cards searched first; best-match suppressed" { // "When resolving a single filename against a delta manifest, an // implementation MUST search the delta's own F cards first, and // best-match (closest-name) lookup MUST be suppressed for delta // manifests even when the caller requests it." (§8.4-m1) const baseline_files = [_]FCard{d8F("a", d8_h1)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta_files = [_]FCard{d8F("a", d8_h2)}; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; const result = try lookup(delta, "a", spy.resolver()); switch (result) { .found => |f| try d8ExpectFCard(d8F("a", d8_h2), f), .not_found => return error.TestUnexpectedResult, } try std.testing.expectEqual(@as(usize, 0), spy.calls); // delta's own card wins, baseline untouched // Near-miss: "ab" is not an exact match for delta's "abc" -- exact-only // means it falls through to the baseline rather than fuzzy-matching, // and the baseline (queried) has no "ab" either. const delta_files2 = [_]FCard{d8F("abc", d8_h2)}; const baseline_files2 = [_]FCard{d8F("abd", d8_h1)}; var spy2 = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files2 } }; const delta2 = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files2 }; const result2 = try lookup(delta2, "ab", spy2.resolver()); try std.testing.expectEqual(LookupResult.not_found, result2); try std.testing.expectEqual(@as(usize, 1), spy2.calls); // fell through to baseline, found nothing there either } test "artifact §8.4: delta delete marker means not-found without consulting baseline" { // "If the delta contains an entry for the name whose hash is absent (a // delete marker), the lookup MUST report not-found without consulting // the baseline." (§8.4-m2) const baseline_files = [_]FCard{d8F("a", d8_h1)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta_files = [_]FCard{d8F("a", null)}; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; const result = try lookup(delta, "a", spy.resolver()); try std.testing.expectEqual(LookupResult.not_found, result); try std.testing.expectEqual(@as(usize, 0), spy.calls); } test "artifact §8.4: name absent from delta means the baseline is resolved and searched" { // "Only when the name is entirely absent from the delta is the // baseline resolved and searched." (§8.4-p1) const baseline_files = [_]FCard{d8F("a", d8_h1)}; var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } }; const delta_files = [_]FCard{d8F("b", d8_h2)}; const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files }; const found = try lookup(delta, "a", spy.resolver()); switch (found) { .found => |f| try d8ExpectFCard(d8F("a", d8_h1), f), .not_found => return error.TestUnexpectedResult, } try std.testing.expectEqual(@as(usize, 1), spy.calls); const missing = try lookup(delta, "c", spy.resolver()); try std.testing.expectEqual(LookupResult.not_found, missing); } test "artifact §9: name and Z-verify operate over different byte extents" { // Lines 2209-2213: name computation covers the complete stored bytes, // wrapper included; Z verification covers the stripped stream. The // third extent the section names -- signature verification over the // pre-strip bytes -- is out of scope for this module (lines 2224-2230 // leave signer trust and verification mechanism outside the document), // so its absence here is not a gap. const s = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++ a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n"; const stripped = stripSignatureWrapper(s); try std.testing.expect(stripped.len < s.len); // wrapper strictly shrinks the input try std.testing.expectEqual(ZVerdict.match, verifyZCard(stripped)); } test "artifact §9: Z-verify input excludes wrapper, name input includes it" { // Lines 2214-2217: feeding the wrong extent to each operation. const s = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++ a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n"; // The wrapped bytes' final 35 bytes are "abc123\n" (7) plus the 28-byte // "-----END PGP SIGNATURE-----\n" armor line, so s[n-35] == 'a', not // 'Z' -- the positional gate rejects before any digest compare runs. try std.testing.expectEqual(ZVerdict.absent, verifyZCard(s)); try std.testing.expectEqual(ZVerdict.match, verifyZCard(stripSignatureWrapper(s))); // Name computation is fed the wrapper on purpose: the inner-content // name never verifies the wrapped bytes, and vice versa. try std.testing.expect(verify(a1_1_name, a1_1_manifest)); try std.testing.expect(!verify(a1_1_name, s)); } test "artifact §9: signed and inner artifacts have distinct independent names" { // Lines 2217-2219: a signed artifact and its unsigned inner content are // distinct artifacts with distinct names, handled independently -- not // merely unequal names but names that bind only their own bytes. const s = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++ a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n"; var buf: [64]u8 = undefined; const n_signed = artifactName(.sha1, s, &buf); try std.testing.expect(!std.mem.eql(u8, n_signed, a1_1_name)); try std.testing.expect(verify(n_signed, s)); try std.testing.expect(!verify(n_signed, a1_1_manifest)); try std.testing.expect(!verify(a1_1_name, s)); } test "artifact §9: token-validation acceptance is not verified integrity" { // Lines 2254-2258: a token that passes format validation (correct // length, all lowercase hex) MUST NOT be treated as verified content -- // that conjunction, ACCEPT-at-token-stage paired with REJECT-at-verify, // is the property under test, not re-tested by either check alone. const zero40: []const u8 = "0" ** 40; const name_rows = [_]struct { tok: []const u8, content: []const u8 }{ .{ .tok = zero40, .content = a1_1_manifest }, // valid 40-hex, wrong value .{ .tok = a1_1_name, .content = "hello world\n" }, // valid name, wrong content }; for (name_rows) |row| { try std.testing.expect(validateName(row.tok)); // token stage: ACCEPT try std.testing.expect(!verify(row.tok, row.content)); // verify stage: REJECT } // Z row: flip one digest digit of the a1_1 manifest (still 32-hex, so // still token-valid), leaving the recomputed checksum mismatched. var flipped: [127]u8 = undefined; @memcpy(&flipped, a1_1_manifest); flipped[94] = 'd'; // digest[0] was 'c' (§A.2 offset table); still hex const digest = flipped[94..126]; var z_line: [35]u8 = undefined; z_line[0] = 'Z'; z_line[1] = ' '; @memcpy(z_line[2..34], digest); z_line[34] = '\n'; try std.testing.expect(parseZCard(tokenizeCardLine(&z_line).?) != null); // token stage: ACCEPT try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&flipped)); // verify stage: REJECT } test "artifact §9: positional Z framing wins over a textual decoy" { // Lines 2276-2280: a repeated "Z " earlier in the artifact is ordinary // content covered by the final checksum; only the final 35 bytes // participate in verification, so a textual scan for "Z " is misled by // the earlier occurrence while the positional check is not. const decoy_digest: []const u8 = "1" ** 32; const prefix = "Z " ++ decoy_digest ++ "\nC filler\n"; var digest_buf: [32]u8 = undefined; const digest = selfChecksum(prefix, &digest_buf); var content_buf: [prefix.len + 35]u8 = undefined; @memcpy(content_buf[0..prefix.len], prefix); content_buf[prefix.len] = 'Z'; content_buf[prefix.len + 1] = ' '; @memcpy(content_buf[prefix.len + 2 .. prefix.len + 34], digest); content_buf[prefix.len + 34] = '\n'; const content: []const u8 = &content_buf; try std.testing.expectEqual(ZVerdict.match, verifyZCard(content)); // The first textual "Z " hit is the decoy at offset 0, strictly before // the real positional Z at content.len - 35. const first_hit = std.mem.indexOf(u8, content, "Z ").?; try std.testing.expect(first_hit < content.len - 35); } test "artifact §9: parse never reads past the buffer on truncation/W-edge" { // Lines 2269-2274: the W card's size-bound and overflow rules, plus // the general "MUST NOT read beyond the end of the artifact buffer // under any malformed input". The uniform-random 0..N sweep is already // covered by the line-5413 full-parse fuzz and the line-5470 parseWCard // fuzz; this sweep targets the exact boundaries those rarely land on. const a = std.testing.allocator; // Every exact prefix of a well-formed artifact: a declared ParseError // or a Manifest, never a trap -- an out-of-bounds slice panics under // Debug/ReleaseSafe, the property under test. var k: usize = 0; while (k <= a1_1_manifest.len) : (k += 1) { if (parse(a, a1_1_manifest[0..k])) |m| { var mm = m; mm.deinit(); } else |_| {} } // W-edge: an 8-byte body followed by its terminator LF and one // trailing filler byte -- "remaining" (bytes after the size line) is // fixed at 10. Only size == remaining-2 can land the r3 check on the // real LF; remaining-1 and remaining put body_end one step from (or // at) the buffer's end, where r4's bound check MUST fire before // artifact[body_end] is ever read. remaining+1 rejects at r2, before // body_end is even computed. The final row overflows the accumulator // (r1) partway through 25 digits, well short of the digit span's own // bound. const body = "orangeee"; comptime std.debug.assert(body.len == 8); const size_rows = [_][]const u8{ "8", "9", "10", "11", "9" ** 25 }; for (size_rows) |digits| { var buf: [64]u8 = undefined; var w: usize = 0; buf[w] = 'W'; w += 1; buf[w] = ' '; w += 1; std.debug.assert(w + digits.len < buf.len - body.len - 2); @memcpy(buf[w .. w + digits.len], digits); w += digits.len; buf[w] = '\n'; w += 1; @memcpy(buf[w .. w + body.len], body); w += body.len; buf[w] = '\n'; w += 1; buf[w] = 'X'; w += 1; const art = buf[0..w]; if (parseWCard(art, 0)) |card| { try std.testing.expect(card.next < art.len); try std.testing.expect(art[card.next - 1] == '\n'); try std.testing.expect(card.body.len <= art.len); } } } fn deepEql(a: anytype, b: @TypeOf(a)) bool { const T = @TypeOf(a); return switch (@typeInfo(T)) { .@"struct" => |info| blk: { inline for (info.fields) |f| { if (!deepEql(@field(a, f.name), @field(b, f.name))) break :blk false; } break :blk true; }, .optional => blk: { const av = a orelse break :blk (b == null); const bv = b orelse break :blk false; break :blk deepEql(av, bv); }, .pointer => |info| switch (info.size) { .slice => if (info.child == u8) std.mem.eql(u8, a, b) else blk2: { if (a.len != b.len) break :blk2 false; for (a, b) |x, y| { if (!deepEql(x, y)) break :blk2 false; } break :blk2 true; }, else => a == b, }, else => a == b, // enums, bools, ints: value equality }; } fn expectManifestEqual(a_m: Manifest, b_m: Manifest) !void { try std.testing.expectEqual(a_m.kind, b_m.kind); const fields = [_][]const u8{ "a", "b", "c", "d", "e", "g", "h", "i", "k", "l", "n", "r", "u", "w", "f", "j", "m", "p", "q", "t", }; inline for (fields) |name| { try std.testing.expect(deepEql(@field(a_m, name), @field(b_m, name))); } } test "artifact §6.2/§A.1.1/§A.2: create round-trips the worked-example manifest byte-for-byte" { const a = std.testing.allocator; var m = try parse(a, a1_1_manifest); defer m.deinit(); const out = try create(&m, a); defer a.free(out); try std.testing.expectEqualSlices(u8, a1_1_manifest, out); try std.testing.expectEqualStrings("Z c62e3619f7c41ee77b33f00df8ec26f4\n", out[out.len - 35 ..]); try std.testing.expectEqual(ZVerdict.match, verifyZCard(out)); } const a3_manifest = rfc_vectors.artifact_a3_rcard_manifest; test "artifact §6.3.4/§A.3: create round-trips the R-card worked example byte-for-byte" { const a = std.testing.allocator; var m = try parse(a, a3_manifest); defer m.deinit(); const out = try create(&m, a); defer a.free(out); try std.testing.expectEqualSlices(u8, a3_manifest, out); try std.testing.expect(std.mem.indexOf(u8, out, "R 95feb799536dc053e7968efb7cdd3ec3\n") != null); try std.testing.expectEqual(ZVerdict.match, verifyZCard(out)); } test "artifact §6.3.4: create emits the R token as 32 lowercase hex even when the parse-accepted token was uppercase" { const a = std.testing.allocator; const upper = "D41D8CD98F00B204E9800998ECF8427E"; // §6.1: case-insensitive on read var prefix_buf: [128]u8 = undefined; const prefix = try std.fmt.bufPrint(&prefix_buf, "C x\nD 2007-07-21T14:09:59\nP\nR {s}\nU drh\n", .{upper}); var digest: [32]u8 = undefined; _ = selfChecksum(prefix, &digest); var art_buf: [256]u8 = undefined; const art = try std.fmt.bufPrint(&art_buf, "{s}Z {s}\n", .{ prefix, digest }); var m = try parse(a, art); defer m.deinit(); const out = try create(&m, a); defer a.free(out); try std.testing.expect(std.mem.indexOf(u8, out, "R d41d8cd98f00b204e9800998ecf8427e\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, "R " ++ upper) == null); } const a1_2_technote = rfc_vectors.artifact_a1_2_sha3_technote; test "artifact §4.7.18/§A.1.2: create round-trips the technote Manifest across parse->create->parse" { const a = std.testing.allocator; var m0 = try parse(a, a1_2_technote); defer m0.deinit(); const out1 = try create(&m0, a); defer a.free(out1); var m1 = try parse(a, out1); defer m1.deinit(); // §A.1.2 stores N-after-P (§4.4 compat exception); the writer always // emits strict-lexical N-before-P -- same Manifest, different bytes, so // this is a data comparison, not a byte comparison. try expectManifestEqual(m0, m1); try std.testing.expect(std.mem.indexOf(u8, out1, "W 414\n") != null); try std.testing.expectEqual(ZVerdict.match, verifyZCard(out1)); // Re-create identity: a second pass over the re-parsed data yields the // exact same bytes -- `create` is a pure function of the Manifest. const out2 = try create(&m1, a); defer a.free(out2); try std.testing.expectEqualSlices(u8, out1, out2); } test "artifact §4.4/§4.7: create emits cards in non-decreasing card-type order" { const a = std.testing.allocator; var m = try parse(a, a3_manifest); defer m.deinit(); const out = try create(&m, a); defer a.free(out); var prev: u8 = 0; var pos: usize = 0; // Bounded by out.len: `frame`'s own §4.1 grammar guarantees every line // (including a W body, opaque here since a3 carries none) ends in LF. while (pos < out.len) { const letter = out[pos]; try std.testing.expect(isCardLetter(letter)); try std.testing.expect(letter >= prev); prev = letter; const lf = std.mem.indexOfScalarPos(u8, out, pos, '\n').?; pos = lf + 1; } } test "artifact §4.5.2: create->parse->deinit of a large manifest crossing an arena node boundary leaks nothing" { const a = std.testing.allocator; var buf: std.ArrayList(u8) = .empty; defer buf.deinit(a); try buf.appendSlice(a, "C large\\stest\\smanifest\n"); try buf.appendSlice(a, "D 2026-07-02T11:18:42.940\n"); const hash64 = "a" ** 64; // any well-formed 64-hex placeholder (§4.7.6 baseline hash) var i: usize = 0; while (i < 300) : (i += 1) { try buf.print(a, "F f{d:0>4}.txt {s}\n", .{ i, hash64 }); } try buf.appendSlice(a, "P " ++ ("b" ** 64) ++ "\n"); i = 0; while (i < 300) : (i += 1) { try buf.print(a, "T +t{d:0>4} *\n", .{i}); } try buf.appendSlice(a, "U drh\n"); var digest: [32]u8 = undefined; _ = selfChecksum(buf.items, &digest); try buf.print(a, "Z {s}\n", .{digest}); const canonical = buf.items; var m = try parse(a, canonical); const out = try create(&m, a); defer a.free(out); try std.testing.expectEqualSlices(u8, canonical, out); var m2 = try parse(a, out); m2.deinit(); m.deinit(); } fn createNoLeak(allocator: std.mem.Allocator, m: *const Manifest) !void { const out = try create(m, allocator); allocator.free(out); } test "artifact §6/§4: create propagates allocation failure" { const a = std.testing.allocator; var m = try parse(a, a1_1_manifest); defer m.deinit(); try std.testing.checkAllAllocationFailures(a, createNoLeak, .{&m}); } test "artifact §4.7.2-r2/§4.3.1: the target is escape-decoded before validation" { // Wire `\a` + 39x'0' decodes to `a` + 39x'0' -- 40 hex chars, accepted // as a hash. 'a' is not one of the 8 recognized escape successors // (escapeOctet), so the unrecognized-escape rule applies: emit the // successor, drop the backslash (§4.3.1 lines 694-698). var buf: [256]u8 = undefined; const wire_target = "\\a" ++ ("0" ** 39); const line = "A x " ++ wire_target ++ "\n"; const card = tokenizeCardLine(line).?; const parsed = parseACard(card, &buf).?; try std.testing.expectEqualStrings("a" ++ ("0" ** 39), parsed.target); } test "artifact §5.3: the eight non-type-setting letters leave the type to fallback" { // S is not in this set -- it is type-setting (submanifests §4.3) -- so the // fallback still resolves to MANIFEST or CONTROL exactly as §5.3 p2 says. // What changed is what happens next: a fallback MANIFEST with no D card is // reassigned to SUBMANIFEST, so D is now the discriminator between the two. const letters8 = "CDNPTUWZ"; const d_bit = censusMask("D"); // Bounded: 2^8 letter-subsets x 2 p_parents values = 512 rounds. var subset: u32 = 0; while (subset < 256) : (subset += 1) { var letters: u32 = 0; for (letters8, 0..) |l, i| { if ((subset & (@as(u32, 1) << @intCast(i))) != 0) { letters |= censusMask(&.{l}); } } for ([_]u8{ 0, 1 }) |p| { const t = determineType(.{ .letters = letters, .p_parents = p }); const manifest_path = (letters & censusMask("C")) != 0 or p >= 1; const expected: ArtifactType = if (!manifest_path) .control else if (letters & d_bit != 0) .manifest else .submanifest; try std.testing.expectEqual(expected, t); } } } test "artifact §5.2: parse rejects a missing required card" { // No type-setting letter and no C/P card -- §5.3 fallback resolves // this to CONTROL, whose required set (D T U Z) is still unmet. try std.testing.expectError(error.MissingRequiredCard, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\n")); } // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 -- the S card and the SUBMANIFEST type // --------------------------------------------------------------------------- // // The seven Appendix A artifacts, byte-for-byte as the draft displays them // (RFC 8792 unfolded). Each file's SHA3-256 is the artifact name the draft // states, so these fixtures are the conformance oracle for §3 and §4. const sm_a2_1_leaf = rfc_vectors.submanifest_a2_1_leaf; const sm_a2_2_leaf = rfc_vectors.submanifest_a2_2_leaf; const sm_a3_manifest = rfc_vectors.submanifest_a3_tree_manifest; const sm_a4_1_leaf = rfc_vectors.submanifest_a4_1_leaf; const sm_a4_2_leaf = rfc_vectors.submanifest_a4_2_leaf; const sm_a4_3_leaf = rfc_vectors.submanifest_a4_3_leaf; const sm_a4_manifest = rfc_vectors.submanifest_a4_tree_manifest; const sm_a2_1_name = "aa9d404c14fe4dadd2ab398961fb0dfe650a877c061f650d8233a5888e920acf"; const sm_a2_2_name = "a50a722b465ed67b42ee0fbe61e4a19d6b1d22940ba5740e99e450921e3f52ea"; const sm_a3_name = "95fba39cbaba76fccb15571516659cea6a0e8a60123c3e7bda1397649d87c509"; const sm_a4_1_name = "b8e53d0c46cb57b616cc0ed6de5ce2453715be6157b73d247bcd5de8a6510aaa"; const sm_a4_2_name = "cff23e5ba71569397c1d816afa2dd0bfffc42339e402df36b63623846bd5fcbe"; const sm_a4_3_name = "9604c3f9774f7472d7bff44d5965f30250358a3e7a18046eeaf5f9d83bd5cdc6"; const sm_a4_name = "02dad8d4451ecf930c0c8637cf66351579aa746aebf0cf10f249b2a6dea37caa"; // The §A.5 "(to author)" vectors. Appendix A stops at the seed of the corpus -- // "The artifacts above are the seed of the conformance corpus, not its extent. // Vectors marked (to author) are specified here and are to be authored with // genuinely computed hashes at implementation time" -- so these eleven // artifacts are authored here, and every name and checksum below was // recomputed off the emitted bytes by a second implementation of §6.1, §6.2 // and §6.3.2 that shares no code with zit (docs note in the slice record). // // Four vectors, one per "(to author)" bullet: // // three_level a manifest over two INTERIOR sub-manifests over the three // Appendix A.4 leaves. §A.4 describes exactly this tree as what // "a writer that merges only at height 0" would emit, so the // vector is both the required three-level acceptance case and // the artifact set the canonical construction MUST NOT produce. // merge the height-0 final-group merge ("a scan ending in a group // shorter than MIN with a predecessor able to absorb it within // MAX"). §A.4 is the interior-height counterpart; height 0 had // no vector. // encoding a tree whose filenames carry SP and backslash, pinning the // §6.2 canonical spelling in leaf F cards AND in S card keys. // delta a delta manifest whose B card names the §A.3 tree manifest. const sm_a5_3lvl_int_1 = rfc_vectors.submanifest_a5_three_level_interior_1; const sm_a5_3lvl_int_2 = rfc_vectors.submanifest_a5_three_level_interior_2; const sm_a5_3lvl_manifest = rfc_vectors.submanifest_a5_three_level_manifest; const sm_a5_merge_1_leaf = rfc_vectors.submanifest_a5_merge_leaf_1; const sm_a5_merge_2_leaf = rfc_vectors.submanifest_a5_merge_leaf_2; const sm_a5_merge_manifest = rfc_vectors.submanifest_a5_merge_manifest; const sm_a5_enc_1_leaf = rfc_vectors.submanifest_a5_encoding_leaf_1; const sm_a5_enc_2_leaf = rfc_vectors.submanifest_a5_encoding_leaf_2; const sm_a5_enc_3_leaf = rfc_vectors.submanifest_a5_encoding_leaf_3; const sm_a5_enc_manifest = rfc_vectors.submanifest_a5_encoding_manifest; const sm_a5_delta_manifest = rfc_vectors.submanifest_a5_delta_manifest; const sm_a5_3lvl_int_1_name = "d42946de7b59647235f2166d964e9e5878bb1b3aba796b5e9c5e9dc719784e23"; const sm_a5_3lvl_int_2_name = "c1cc48fa9a3fbc34da7614632137890b4b09b33e750abc75514836c533e5f42e"; const sm_a5_3lvl_manifest_name = "c136ef62f5f13c18d06564649da6022d9fe25cde5be15dd1ad10b04015096424"; const sm_a5_merge_1_name = "4bf4476e3aa41a1e3d26c85173c18fcd19f61871d4fc31215e523d7502078cc8"; const sm_a5_merge_2_name = "6f3c993caa208b6fd44332d241676f9aa6a8c29dc7e28f287d5cdc088005a842"; const sm_a5_merge_manifest_name = "cef55b9b325fbc8abd59a044acafa57603c1b11a605e7fbba02d511a6da7a806"; const sm_a5_enc_1_name = "e2899273b6a258ee18e053a79903d02469a275e2c80279c3657c7b803e2af6c0"; const sm_a5_enc_2_name = "d7edb65681faaa4fd4f3541f04370421b057bf299098b781c7ec50d609869082"; const sm_a5_enc_3_name = "c634332663f1cc0298aae6b61c799214a522877102cd6acdfe633f29fd79eeaf"; const sm_a5_enc_manifest_name = "deb0cb8b56fddfc4eebbab0f928cbdb19e64a6ba568064d5b43663dc1ef7263f"; const sm_a5_delta_manifest_name = "c74e37f650677523f7b6c2b4f094f766c4427a8ea180768ad75fa2de6bd14902"; test "submanifests §A.2/§A.3/§A.4: every vector is named by the SHA3-256 the draft states" { // Appendix A preamble: "every artifact name, checksum, and byte-count // claim recomputes from the exact displayed byte sequence". const Row = struct { bytes: []const u8, len: usize, name: []const u8 }; const rows = [_]Row{ .{ .bytes = sm_a2_1_leaf, .len = 190, .name = sm_a2_1_name }, .{ .bytes = sm_a2_2_leaf, .len = 193, .name = sm_a2_2_name }, .{ .bytes = sm_a3_manifest, .len = 281, .name = sm_a3_name }, .{ .bytes = sm_a4_1_leaf, .len = 185, .name = sm_a4_1_name }, .{ .bytes = sm_a4_2_leaf, .len = 335, .name = sm_a4_2_name }, .{ .bytes = sm_a4_3_leaf, .len = 260, .name = sm_a4_3_name }, .{ .bytes = sm_a4_manifest, .len = 350, .name = sm_a4_name }, }; for (rows) |row| { try std.testing.expectEqual(row.len, row.bytes.len); var out: [64]u8 = undefined; try std.testing.expectEqualStrings(row.name, artifactName(.sha3_256, row.bytes, &out)); try std.testing.expect(verify(row.name, row.bytes)); } } test "submanifests §4.1/§4.3: every leaf vector classifies as SUBMANIFEST" { // §A.2: "Both artifacts are of type SUBMANIFEST by the reassignment rule // of Section 4.3: their F cards assign MANIFEST, and no D card is // present." §A.4's three leaves have the same shape. const leaves = [_][]const u8{ sm_a2_1_leaf, sm_a2_2_leaf, sm_a4_1_leaf, sm_a4_2_leaf, sm_a4_3_leaf }; for (leaves) |bytes| { var m = try parse(std.testing.allocator, bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.submanifest, m.kind); } } test "submanifests §A.3/§A.4: a tree manifest stays MANIFEST and carries its S run" { // §5: "A manifest is a tree manifest if and only if it contains an S // card." The D card is present, so §4.3's reassignment does not fire. var m3 = try parse(std.testing.allocator, sm_a3_manifest); defer m3.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m3.kind); try std.testing.expectEqual(@as(usize, 0), m3.f.len); var m4 = try parse(std.testing.allocator, sm_a4_manifest); defer m4.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m4.kind); try std.testing.expectEqual(@as(usize, 0), m4.f.len); } test "submanifests §4.3: the reassignment is a post-pass keyed on the absence of D" { // §4.3: "an artifact whose finalized type is MANIFEST and which contains // no D card MUST be reclassified as SUBMANIFEST (code 9)." const f_only = Census{ .letters = censusMask("FZ") }; try std.testing.expectEqual(ArtifactType.submanifest, determineType(f_only)); try std.testing.expectEqual(ArtifactType.submanifest, try classify(f_only)); const s_only = Census{ .letters = censusMask("SZ") }; try std.testing.expectEqual(ArtifactType.submanifest, determineType(s_only)); try std.testing.expectEqual(ArtifactType.submanifest, try classify(s_only)); // With D present the finalized type is MANIFEST and stays MANIFEST. const with_d = Census{ .letters = censusMask("DFZ") }; try std.testing.expectEqual(ArtifactType.manifest, determineType(with_d)); try std.testing.expectEqual(ArtifactType.manifest, try classify(with_d)); } test "submanifests §4.2: SUBMANIFEST allows only F S Z and requires Z" { // Table 1: "| 9 | SUBMANIFEST | F S Z | Z |", and §4.3: "a D-less // artifact carrying any card outside F, S, Z is rejected". Each probe // pairs the letter under test with F and Z; D itself is excluded, // being the discriminator ({D,F,Z} is a valid flat MANIFEST). // // For these letters the fold still finalizes MANIFEST -- they are // non-type-setting, or set MANIFEST themselves, or (A, E) are // overwritten by F, which sorts later -- so the reassignment fires and // it is SUBMANIFEST's own F-S-Z row that does the rejecting. for ("ABCENPQRTUW") |c| { std.debug.assert(isCardLetter(c)); const letters = censusMask(&.{ 'F', 'Z', c }); try std.testing.expectError(error.DisallowedCard, classify(.{ .letters = letters })); } // G through M sort after F and set a non-MANIFEST type, so no // reassignment happens; the F card is rejected by that type's row // instead. Either way a D-less artifact never survives with a card // outside F, S, Z. for ("GHIJKLM") |c| { std.debug.assert(isCardLetter(c)); const letters = censusMask(&.{ 'F', 'Z', c }); try std.testing.expectError(error.DisallowedCard, classify(.{ .letters = letters })); } // Z is required (Table 1's Required column). try std.testing.expectError(error.MissingRequiredCard, classify(.{ .letters = censusMask("F") })); } // A 64-hex artifact-hash token for the hand-authored S-card fixtures below. // Two distinct values, ordered so either may stand in for a real name. const SM_H1: []const u8 = "1" ** 64; const SM_H2: []const u8 = "2" ** 64; test "submanifests §3.6: the S card grammar accepts filename plus raw hash" { // "s-card = %x53 SP filename SP hash LF". var buf: [256]u8 = undefined; const card = tokenizeCardLine("S src/main.c " ++ ("a" ** 64) ++ "\n").?; const sc = parseSCard(card, &buf).?; try std.testing.expectEqualStrings("src/main.c", sc.filename); try std.testing.expectEqualStrings("a" ** 64, sc.hash); } test "submanifests §3.5/§3.6: an S hash the parser accepts is one the writer can re-emit" { // §3.5 validates the S hash case-insensitively (§4.7.1-m1) and §3.6 keeps // it raw -- "the hash is never decoded" -- so the parser preserves the // spelling it was given. A writer that accepts only one spelling cannot // re-emit what the parser just accepted. const gpa = std.testing.allocator; const upper = "A" ** 40; const subs = [_]SCard{smSub("src/main.c", upper)}; var m: Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .manifest, .a = null, .b = null, .c = null, .d = .{ .timestamp = "2026-07-26T00:00:00" }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = null, .u = null, .z = null, .w = null, .f = &.{}, .j = &.{}, .m = &.{}, .p = &.{}, .q = &.{}, .s = &subs, .t = &.{}, }; defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expect(std.mem.indexOf(u8, bytes, "S src/main.c " ++ upper ++ "\n") != null); } test "submanifests §3.3: the S card filename is escape-decoded, the hash never is" { // §3.3: "The decoder ... applies at ... the S card filename. The S card // hash is never decoded." A 40-hex hash spelled with an escape would // decode to 39 characters, so accepting it would prove decoding happened. var buf: [256]u8 = undefined; const card = tokenizeCardLine("S a\\sb " ++ ("0" ** 64) ++ "\n").?; const sc = parseSCard(card, &buf).?; try std.testing.expectEqualStrings("a b", sc.filename); const escaped_hash = tokenizeCardLine("S a.txt \\a" ++ ("0" ** 39) ++ "\n").?; try std.testing.expect(parseSCard(escaped_hash, &buf) == null); } test "submanifests §3.2/§3.5/§3.6: the S card rejects bad arity, hash, and pathname" { var buf: [256]u8 = undefined; const bad = [_][]const u8{ "S\n", // no tokens at all "S a.txt\n", // §3.6: the hash is mandatory "S a.txt " ++ ("a" ** 64) ++ " x\n", // §3.2: a third token on S is rejected "S a.txt " ++ ("a" ** 63) ++ "\n", // §3.5: 63 hex is neither 40 nor 64 "S a.txt " ++ ("g" ** 64) ++ "\n", // §3.5: not hexadecimal "S ../a.txt " ++ ("a" ** 64) ++ "\n", // §3.6 -> §4.6.1: ".." element "S /a.txt " ++ ("a" ** 64) ++ "\n", // §3.6 -> §4.6.1: empty leading element "S " ++ ("a" ** 64) ++ "\n", // §4.6.1: empty filename }; for (bad) |line| { const card = tokenizeCardLine(line).?; try std.testing.expect(parseSCard(card, &buf) == null); } // §3.5 admits both artifact-name lengths, in either case. for ([_][]const u8{ "a" ** 40, "A" ** 64 }) |hash| { var line_buf: [128]u8 = undefined; const line = try std.fmt.bufPrint(&line_buf, "S a.txt {s}\n", .{hash}); const card = tokenizeCardLine(line).?; try std.testing.expectEqualStrings(hash, parseSCard(card, &buf).?.hash); } } test "submanifests §3.4: an S run must strictly ascend by decoded filename" { // "S | Strictly ascending by decoded filename (equal or descending // rejected)", under the canonical comparator. var buf: [512]u8 = undefined; const ascending = buildArtifact(&buf, "S a.txt " ++ SM_H1 ++ "\nS b.txt " ++ SM_H2 ++ "\n"); var m = try parse(std.testing.allocator, ascending); defer m.deinit(); try std.testing.expectEqual(@as(usize, 2), m.s.len); var buf2: [512]u8 = undefined; const descending = buildArtifact(&buf2, "S b.txt " ++ SM_H1 ++ "\nS a.txt " ++ SM_H2 ++ "\n"); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, descending)); var buf3: [512]u8 = undefined; const equal = buildArtifact(&buf3, "S a.txt " ++ SM_H1 ++ "\nS a.txt " ++ SM_H2 ++ "\n"); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, equal)); } test "submanifests §4.5: the pre-filter admits an artifact whose first card is S" { // "an implementation may reject any input whose first card letter falls // in N through Z, excluding S ... because an interior sub-manifest's // smallest allowed card letter is S." The artifact below is exactly that // interior shape: S cards and a Z card, nothing else. var buf: [512]u8 = undefined; const interior = buildArtifact(&buf, "S a.txt " ++ SM_H1 ++ "\nS b.txt " ++ SM_H2 ++ "\n"); var m = try parse(std.testing.allocator, interior); defer m.deinit(); try std.testing.expectEqual(ArtifactType.submanifest, m.kind); try std.testing.expectEqual(@as(usize, 2), m.s.len); try std.testing.expectEqualStrings("a.txt", m.s[0].filename); try std.testing.expectEqualStrings(SM_H1, m.s[0].hash); try std.testing.expectEqualStrings("b.txt", m.s[1].filename); // The letters the revised pre-filter still turns away. for ([_][]const u8{ "T +x " ++ SM_H1 ++ "\n", "U drh\n", "W 3\nabc\n" }) |body| { var reject_buf: [512]u8 = undefined; const bytes = buildArtifact(&reject_buf, body); try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes)); } } test "submanifests §4.4: a manifest rejects B+S and F+S" { // "A MANIFEST MUST NOT contain both a B card and an S card." / "A // MANIFEST MUST NOT contain both an F card and an S card: the file set is // conveyed entirely by one or the other, never a mixture." var buf: [1024]u8 = undefined; const b_and_s = buildArtifact(&buf, "B " ++ SM_H1 ++ "\nD " ++ TEST_TS ++ "\nS a.txt " ++ SM_H2 ++ "\n"); try std.testing.expectError(error.ManifestBaselineAndSCard, parse(std.testing.allocator, b_and_s)); var buf2: [1024]u8 = undefined; const f_and_s = buildArtifact(&buf2, "D " ++ TEST_TS ++ "\nF a.txt " ++ SM_H1 ++ "\nS a.txt " ++ SM_H2 ++ "\n"); try std.testing.expectError(error.ManifestFileAndSCard, parse(std.testing.allocator, f_and_s)); } test "submanifests §4.4: a sub-manifest rejects F+S and a hashless F card" { // "A SUBMANIFEST MUST contain at least one F card or at least one S card, // and MUST NOT contain both. Every F card in a SUBMANIFEST MUST carry a // hash: the delete-marker form ... is permitted only in a delta manifest." var buf: [1024]u8 = undefined; const f_and_s = buildArtifact(&buf, "F a.txt " ++ SM_H1 ++ "\nS a.txt " ++ SM_H2 ++ "\n"); try std.testing.expectError(error.SubmanifestFileAndSCard, parse(std.testing.allocator, f_and_s)); var buf2: [1024]u8 = undefined; const hashless = buildArtifact(&buf2, "F a.txt\n"); try std.testing.expectError(error.SubmanifestFileWithoutHash, parse(std.testing.allocator, hashless)); // One hashless card among several still rejects: the check is over the // whole F run, not just the first card. var buf3: [1024]u8 = undefined; const mixed = buildArtifact(&buf3, "F a.txt " ++ SM_H1 ++ "\nF b.txt\n"); try std.testing.expectError(error.SubmanifestFileWithoutHash, parse(std.testing.allocator, mixed)); // The same delete marker is fine in a delta manifest, which keeps a D card // and so is never reassigned. var buf4: [1024]u8 = undefined; const delta = buildArtifact(&buf4, "B " ++ SM_H1 ++ "\nD " ++ TEST_TS ++ "\nF a.txt\n"); var m = try parse(std.testing.allocator, delta); defer m.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m.kind); } test "submanifests §4.4: a SUBMANIFEST carrying neither F nor S is unreachable from bytes" { // The semantic check exists (it is stated as a MUST), but §4.2 legality // runs first and kills every byte sequence that could reach it: the only // D-less routes to a finalized MANIFEST without F or S are B, Q, R, and // the C/P fallback, and none of those letters is in SUBMANIFEST's allowed // set. `checkSemantics` is exercised directly for the check itself // (artifact.zig's inline tests); here the byte-level outcome is pinned. var buf: [1024]u8 = undefined; const Row = struct { body: []const u8, want: ParseError }; for ([_]Row{ .{ .body = "B " ++ SM_H1 ++ "\n", .want = error.DisallowedCard }, .{ .body = "C hello\n", .want = error.DisallowedCard }, // Q and R sort above 'M' and are not S, so the §4.5 pre-filter turns // them away before classification even runs -- a rejection either way. .{ .body = "Q +" ++ SM_H1 ++ "\n", .want = error.SyntaxError }, .{ .body = "R d41d8cd98f00b204e9800998ecf8427e\n", .want = error.SyntaxError }, }) |row| { const bytes = buildArtifact(&buf, row.body); try std.testing.expectError(row.want, parse(std.testing.allocator, bytes)); } } /// The Appendix A.1 / A.4 file contents, verbatim from the draft's tables. /// The R-card verification below needs the bytes, not just the names. const SmFile = struct { name: []const u8, content: []const u8 }; const sm_a1_files = [_]SmFile{ .{ .name = "README.md", .content = "# Example\n" }, .{ .name = "src/main.c", .content = "int main(void){return 0;}\n" }, .{ .name = "src/node11.c", .content = "/* node11 */\n" }, .{ .name = "src/util.c", .content = "/* util */\n" }, }; const sm_a4_files = [_]SmFile{ .{ .name = "f1257.c", .content = "/* 1257 */\n" }, .{ .name = "f1346.c", .content = "/* 1346 */\n" }, .{ .name = "f1523.c", .content = "/* 1523 */\n" }, .{ .name = "f2146.c", .content = "/* 2146 */\n" }, .{ .name = "f2260.c", .content = "/* 2260 */\n" }, .{ .name = "f2468.c", .content = "/* 2468 */\n" }, .{ .name = "f2614.c", .content = "/* 2614 */\n" }, .{ .name = "f2954.c", .content = "/* 2954 */\n" }, .{ .name = "f3514.c", .content = "/* 3514 */\n" }, }; /// The fixture content for an enumerated filename. A miss means a leaf named a /// file the fixture set does not hold -- report which one rather than panicking /// anonymously, since that mismatch is exactly what these tests are looking for. fn smContentOf(files: []const SmFile, name: []const u8) ![]const u8 { for (files) |f| { if (std.mem.eql(u8, f.name, name)) return f.content; } std.debug.print("no fixture file named \"{s}\"\n", .{name}); return error.TestUnexpectedResult; } /// Concatenate the leaves' F runs in S-card order and check the result /// against the manifest's R card and against the expected enumeration. /// /// §5.6 is what makes this possible without the Section 5.1 walk machinery: /// "a tree's list is a concatenation, not a merge: leaves hold contiguous /// slices of the sorted namespace, so emitting each leaf's F run in S card /// order is the enumeration." The concatenation is done here, in the test, /// precisely because the walk itself is not this slice's work. fn smCheckTree( manifest_bytes: []const u8, leaves: []const []const u8, files: []const SmFile, ) !void { var m = try parse(std.testing.allocator, manifest_bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m.kind); try std.testing.expectEqual(leaves.len, m.s.len); var tracked: std.ArrayList(TrackedFile) = .empty; defer tracked.deinit(std.testing.allocator); // Every leaf stays alive to the end of the check: the decoded filenames // `tracked` accumulates are owned by each leaf's arena. var parsed: std.ArrayList(Manifest) = .empty; defer { for (parsed.items) |*leaf| leaf.deinit(); parsed.deinit(std.testing.allocator); } for (m.s, leaves) |sc, leaf_bytes| { // §A.3: "each S card's hash is the leaf artifact name". Recomputing // it here is what ties the tree together without a resolver. var name_buf: [64]u8 = undefined; try std.testing.expectEqualStrings(artifactName(.sha3_256, leaf_bytes, &name_buf), sc.hash); try parsed.append(std.testing.allocator, try parse(std.testing.allocator, leaf_bytes)); // Borrowed, not owned: the list element keeps the arena the decoded // slices below point into, and `defer` above still deinits it. const leaf = &parsed.items[parsed.items.len - 1]; try std.testing.expectEqual(ArtifactType.submanifest, leaf.kind); try std.testing.expect(leaf.f.len >= 1); // §3.6 / §5.1 step 4: the S card's filename is the least member of // the slice, byte-identical to the leaf's first F card filename. try std.testing.expectEqualStrings(leaf.f[0].filename, sc.filename); for (leaf.f) |fc| { try std.testing.expect(fc.hash != null); // §4.4: every F carries a hash const content = try smContentOf(files, fc.filename); // §7 hash policy: the fixtures are named under sha3. var file_name_buf: [64]u8 = undefined; try std.testing.expectEqualStrings( artifactName(.sha3_256, content, &file_name_buf), fc.hash.?, ); try tracked.append(std.testing.allocator, .{ .name = fc.filename, .content = content }); } } // The concatenation reproduces the flat enumeration, in order. try std.testing.expectEqual(files.len, tracked.items.len); for (files, tracked.items) |want, got| { try std.testing.expectEqualStrings(want.name, got.name); } // §5.5: "the R card MUST be computed over the walk enumeration ... after // which the computation of [FossilArtifact], Section 6.3.2 applies // byte-for-byte unchanged." var digest: [32]u8 = undefined; try std.testing.expect(m.r != null); try std.testing.expectEqualStrings(m.r.?.checksum, aggregateChecksum(tracked.items, &digest)); } test "submanifests §A.3: the tree manifest's R card covers the concatenated leaf enumeration" { // §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c, // src/node11.c, src/util.c: the same list a flat manifest would carry", // and "MD5 of these 117 bytes = 152bc688378069a3c22a1a4c55bc8b1a, which // matches the R card." try smCheckTree(sm_a3_manifest, &.{ sm_a2_1_leaf, sm_a2_2_leaf }, &sm_a1_files); } test "submanifests §A.4: the interior-merge tree's R card covers its nine-name enumeration" { // §A.4: three leaves of 2, 4, and 3 entries, the height-1 merge firing so // the manifest carries three S cards directly, "its R card the MD5 of the // 198-byte R-card input formed from the walked enumeration". try smCheckTree( sm_a4_manifest, &.{ sm_a4_1_leaf, sm_a4_2_leaf, sm_a4_3_leaf }, &sm_a4_files, ); } test "submanifests §A.2/§A.4: every leaf vector's F run is its slice, in order" { const Row = struct { bytes: []const u8, names: []const []const u8 }; const rows = [_]Row{ .{ .bytes = sm_a2_1_leaf, .names = &.{ "README.md", "src/main.c" } }, .{ .bytes = sm_a2_2_leaf, .names = &.{ "src/node11.c", "src/util.c" } }, .{ .bytes = sm_a4_1_leaf, .names = &.{ "f1257.c", "f1346.c" } }, .{ .bytes = sm_a4_2_leaf, .names = &.{ "f1523.c", "f2146.c", "f2260.c", "f2468.c" } }, .{ .bytes = sm_a4_3_leaf, .names = &.{ "f2614.c", "f2954.c", "f3514.c" } }, }; for (rows) |row| { var m = try parse(std.testing.allocator, row.bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.submanifest, m.kind); try std.testing.expectEqual(@as(usize, 0), m.s.len); // a leaf, not interior try std.testing.expectEqual(row.names.len, m.f.len); for (row.names, m.f) |want, fc| try std.testing.expectEqualStrings(want, fc.filename); // A sub-manifest carries no state-bearing card: no D, no U, no R. try std.testing.expect(m.d == null); try std.testing.expect(m.u == null); try std.testing.expect(m.r == null); try std.testing.expect(m.z != null); // §4.2 Table 1: Z is required } } test "submanifests §A.3/§A.4: a tree manifest's S run carries the keys the draft states" { var m3 = try parse(std.testing.allocator, sm_a3_manifest); defer m3.deinit(); try std.testing.expectEqual(@as(usize, 2), m3.s.len); try std.testing.expectEqualStrings("README.md", m3.s[0].filename); try std.testing.expectEqualStrings(sm_a2_1_name, m3.s[0].hash); try std.testing.expectEqualStrings("src/node11.c", m3.s[1].filename); try std.testing.expectEqualStrings(sm_a2_2_name, m3.s[1].hash); // §3.4: the S run precedes the T cards and follows R -- here R is present // and parsed, proving the inter-type order accepted R before S. try std.testing.expect(m3.r != null); try std.testing.expectEqualStrings("152bc688378069a3c22a1a4c55bc8b1a", m3.r.?.checksum); var m4 = try parse(std.testing.allocator, sm_a4_manifest); defer m4.deinit(); try std.testing.expectEqual(@as(usize, 3), m4.s.len); try std.testing.expectEqualStrings("f1257.c", m4.s[0].filename); try std.testing.expectEqualStrings(sm_a4_1_name, m4.s[0].hash); try std.testing.expectEqualStrings("f1523.c", m4.s[1].filename); try std.testing.expectEqualStrings(sm_a4_2_name, m4.s[1].hash); try std.testing.expectEqualStrings("f2614.c", m4.s[2].filename); try std.testing.expectEqualStrings(sm_a4_3_name, m4.s[2].hash); try std.testing.expectEqualStrings("c1155756fd2fa431f9c663496775f8e2", m4.r.?.checksum); } // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 §5 -- tree walk, resolution, lookup, R card // --------------------------------------------------------------------------- /// An in-memory §5.2 resolver over a table of parsed artifacts keyed by /// artifact name. `kind` is carried so the two §5.2 hard failures -- absent, /// and present but not of type SUBMANIFEST -- collapse into the one `null` /// the seam models, exactly as a store-backed resolver collapses them. `reads` /// counts resolutions, so §5.3's "at most 16 per lookup" is measured rather /// than assumed. const SmStore = struct { entries: []const Entry, reads: usize = 0, const Entry = struct { name: []const u8, view: SubManifestView, kind: ArtifactType = .submanifest, }; fn resolve(ctx: *anyopaque, hash: []const u8) ?SubManifestView { const self: *SmStore = @ptrCast(@alignCast(ctx)); for (self.entries) |e| { if (!std.mem.eql(u8, e.name, hash)) continue; if (e.kind != .submanifest) return null; // §5.2: wrong type ⇒ unresolvable self.reads += 1; return e.view; } return null; // §5.2: absent ⇒ unresolvable } fn seam(self: *SmStore) SubResolver { return .{ .ctx = self, .resolveFn = SmStore.resolve }; } }; /// Parses a set of artifacts and exposes them as an `SmStore` table keyed by /// each one's real SHA3-256 name, so the S-card hashes the vectors carry /// resolve exactly as they would out of a repository. const SmCorpus = struct { gpa: std.mem.Allocator, parsed: std.ArrayList(Manifest) = .empty, names: std.ArrayList([64]u8) = .empty, entries: std.ArrayList(SmStore.Entry) = .empty, fn init(gpa: std.mem.Allocator, artifacts: []const []const u8) !SmCorpus { var self = SmCorpus{ .gpa = gpa }; errdefer self.deinit(); for (artifacts) |bytes| { var name: [64]u8 = undefined; _ = artifactName(.sha3_256, bytes, &name); try self.parsed.append(gpa, try parse(gpa, bytes)); try self.names.append(gpa, name); } // Entry slices are taken only after every append, so no growth of // `names` can invalidate a name pointer recorded here. for (self.parsed.items, self.names.items) |*m, *n| { try self.entries.append(gpa, .{ .name = n, .view = .{ .files = m.f, .subs = m.s }, .kind = m.kind, }); } return self; } fn store(self: *SmCorpus) SmStore { return .{ .entries = self.entries.items }; } fn deinit(self: *SmCorpus) void { for (self.parsed.items) |*m| m.deinit(); self.parsed.deinit(self.gpa); self.names.deinit(self.gpa); self.entries.deinit(self.gpa); } }; /// Flatten a tree manifest through the §5.1 walk, then check the enumeration /// against the fixture list, every emitted hash against the fixture content, /// and the manifest's R card against §5.5's aggregate over that enumeration. fn smWalkTree(manifest_bytes: []const u8, subs: []const []const u8, files: []const SmFile) !void { const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, subs); defer corpus.deinit(); var st = corpus.store(); var m = try parse(gpa, manifest_bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.manifest, m.kind); try std.testing.expect(m.s.len > 0); try std.testing.expectEqual(@as(usize, 0), m.f.len); // §4.4: never both F and S try m.flattenTree(st.seam()); const flat = m.flattened.?; var tracked: std.ArrayList(TrackedFile) = .empty; defer tracked.deinit(gpa); try std.testing.expectEqual(files.len, flat.len); for (files, flat) |want, got| { try std.testing.expectEqualStrings(want.name, got.filename); // §4.4: every F card reachable through a tree carries a hash. var hbuf: [64]u8 = undefined; try std.testing.expectEqualStrings(artifactName(.sha3_256, want.content, &hbuf), got.hash.?); try tracked.append(gpa, .{ .name = got.filename, .content = want.content }); } // §5.5: "the R card MUST be computed over the walk enumeration of // Section 5.1 ... after which the computation of [FossilArtifact], // Section 6.3.2 applies byte-for-byte unchanged." var digest: [32]u8 = undefined; try std.testing.expectEqualStrings(m.r.?.checksum, aggregateChecksum(tracked.items, &digest)); } test "submanifests §5.1/§5.5: the A.3 tree walks to its four files and its R card verifies" { // §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c, // src/node11.c, src/util.c: the same list a flat manifest would carry." try smWalkTree(sm_a3_manifest, &.{ sm_a2_1_leaf, sm_a2_2_leaf }, &sm_a1_files); } test "submanifests §5.1/§5.5: the A.4 tree walks to its nine files and its R card verifies" { // §A.4: "The walk of Section 5.1 enumerates the nine names in filename // order", the R card "the MD5 of the 198-byte R-card input formed from // the walked enumeration per Section 5.5." try smWalkTree( sm_a4_manifest, &.{ sm_a4_1_leaf, sm_a4_2_leaf, sm_a4_3_leaf }, &sm_a4_files, ); } test "submanifests §5.1 step 5: the three-level vector walks through both interior nodes" { // §A.5 required vector: "a three-level tree (manifest, interior nodes, // leaves), exercising step 5 of the tree walk." Step 5's byte-identity // check holds at both interiors -- each one's first S filename is the token // the manifest's S card carries. // // Two S cards in the manifest's own frame, not one, is what makes this the // step-5 vector rather than a shape that merely has three levels: after the // first interior's subtree is exhausted the walk must resume the MANIFEST's // frame at its second S card. A reader that advanced the wrong frame's // cursor -- the reading the step-5 erratum closes -- either re-enters the // first interior or terminates four files early, and either way the // enumeration below is short. try smWalkTree( sm_a5_3lvl_manifest, &.{ sm_a4_1_leaf, sm_a4_2_leaf, sm_a4_3_leaf, sm_a5_3lvl_int_1, sm_a5_3lvl_int_2 }, &sm_a4_files, ); } // --- §5.1 rejection cases --------------------------------------------------- // // Hand-built views rather than parsed artifacts: a cycle, a 17-artifact spine, // and a leaf whose first filename disagrees with its key are shapes `parse` // cannot produce on its own, and the walk owns every one of those checks. fn smSub(name: []const u8, hash: []const u8) SCard { return .{ .filename = name, .hash = hash }; } fn smFile(name: []const u8) FCard { return .{ .filename = name, .hash = "a" ** 64, .perm = null, .old_name = null, .mode = .regular }; } /// Drive the §5.1 walk to exhaustion over a hand-built tree, appending each /// emitted filename. Any `TreeError` propagates as the walk raised it. fn smDrive( gpa: std.mem.Allocator, subs: []const SCard, nodes: []const SmStore.Entry, out: *std.ArrayList([]const u8), ) !void { var st = SmStore{ .entries = nodes }; var walk = TreeWalk.init(subs, st.seam()); while (try walk.next()) |f| try out.append(gpa, f.filename); } fn smExpectWalkError(want: anyerror, subs: []const SCard, nodes: []const SmStore.Entry) !void { const gpa = std.testing.allocator; var out: std.ArrayList([]const u8) = .empty; defer out.deinit(gpa); try std.testing.expectError(want, smDrive(gpa, subs, nodes, &out)); } test "submanifests §5.1 step 3: an absent sub-manifest fails the walk hard" { // §5.2: "the enumeration entry points ... MUST treat an unresolvable // sub-manifest (absent, or present but not of type SUBMANIFEST) as a // fatal error." try smExpectWalkError(error.SubUnresolvable, &.{smSub("a", "missing")}, &.{}); } test "submanifests §5.1 step 3: an S reference to a non-SUBMANIFEST fails the walk" { // §3.6: "The referenced artifact MUST be of type SUBMANIFEST; a reference // to any other type MUST cause the tree walk to fail." The A.3 tree // manifest is a genuine artifact of the wrong type. const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, &.{sm_a3_manifest}); defer corpus.deinit(); var st = corpus.store(); try std.testing.expectEqual(ArtifactType.manifest, corpus.entries.items[0].kind); var walk = TreeWalk.init(&.{smSub("README.md", sm_a3_name)}, st.seam()); try std.testing.expectError(error.SubUnresolvable, walk.next()); } test "submanifests §5.1 step 3: a reference cycle fails the walk" { // §5.1 step 3: "the referenced artifact is the artifact of any frame // currently on the stack". §10: "the depth bound and the repeated-artifact // check ... MUST be enforced -- together they reject reference cycles and // bound the stack." const n0_subs = [_]SCard{smSub("a", "n1")}; const n1_subs = [_]SCard{smSub("a", "n0")}; const nodes = [_]SmStore.Entry{ .{ .name = "n0", .view = .{ .files = &.{}, .subs = &n0_subs } }, .{ .name = "n1", .view = .{ .files = &.{}, .subs = &n1_subs } }, }; try smExpectWalkError(error.ReferenceCycle, &.{smSub("a", "n0")}, &nodes); } /// A single-key spine: `levels` interior sub-manifests chained under key "a", /// with one leaf holding "a" at the bottom. Depth counts every sub-manifest on /// the path including the leaf (§2), so the spine's depth is `levels + 1`. const SmSpine = struct { names: [24][3]u8 = undefined, subs: [24][1]SCard = undefined, files: [1]FCard = undefined, nodes: [24]SmStore.Entry = undefined, n: usize = 0, fn build(self: *SmSpine, levels: usize) []const SmStore.Entry { std.debug.assert(levels + 1 <= self.nodes.len); self.files = .{smFile("a")}; for (0..levels + 1) |i| { self.names[i] = .{ 'n', @intCast('0' + i / 10), @intCast('0' + i % 10) }; } for (0..levels) |i| { self.subs[i] = .{smSub("a", &self.names[i + 1])}; self.nodes[i] = .{ .name = &self.names[i], .view = .{ .files = &.{}, .subs = &self.subs[i] } }; } self.nodes[levels] = .{ .name = &self.names[levels], .view = .{ .files = &self.files, .subs = &.{} } }; self.n = levels + 1; return self.nodes[0..self.n]; } fn root(self: *const SmSpine) SCard { return smSub("a", &self.names[0]); } }; test "submanifests §5.1 step 3: a path of exactly 16 sub-manifests walks" { // §2: "the depth bound of 16 admits at most 16 levels and a walk of at // most 17 frames." Fifteen interiors plus the leaf is depth 16 -- legal. const gpa = std.testing.allocator; var spine: SmSpine = .{}; const nodes = spine.build(15); var out: std.ArrayList([]const u8) = .empty; defer out.deinit(gpa); try smDrive(gpa, &.{spine.root()}, nodes, &out); try std.testing.expectEqual(@as(usize, 1), out.items.len); try std.testing.expectEqualStrings("a", out.items[0]); } test "submanifests §5.1 step 3: a path of 17 sub-manifests fails the walk" { // §5.1 step 3: "the referenced artifact's depth ... exceeds 16." // §A.5 rejection vector: "a path of more than 16 sub-manifest artifacts, // counting the leaf". var spine: SmSpine = .{}; const nodes = spine.build(16); try smExpectWalkError(error.DepthExceeded, &.{spine.root()}, nodes); } test "submanifests §5.1 step 4: a leaf whose first filename is not its key fails" { // §5.1 step 4: "The first emitted decoded filename MUST be byte-identical // to the S card's decoded filename." const leaf = [_]FCard{ smFile("b"), smFile("c") }; const nodes = [_]SmStore.Entry{ .{ .name = "n0", .view = .{ .files = &leaf, .subs = &.{} } }, }; try smExpectWalkError(error.FirstFilenameMismatch, &.{smSub("a", "n0")}, &nodes); } test "submanifests §5.1 step 5: an interior whose first S filename is not its key fails" { // §5.1 step 5: "its first S card's decoded filename MUST be byte-identical // to the referencing S card's decoded filename." const leaf = [_]FCard{smFile("b")}; const interior = [_]SCard{smSub("b", "n1")}; const nodes = [_]SmStore.Entry{ .{ .name = "n0", .view = .{ .files = &.{}, .subs = &interior } }, .{ .name = "n1", .view = .{ .files = &leaf, .subs = &.{} } }, }; try smExpectWalkError(error.FirstFilenameMismatch, &.{smSub("a", "n0")}, &nodes); } test "submanifests §5.1 step 3: an enumeration not ascending across sibling slices fails" { // §A.5 rejection vector: "an enumeration that does not strictly ascend // across sibling slices." The S run itself ascends (a < b); only the walk // sees that the first leaf already emitted "m", so the second key is no // longer strictly greater than LAST. const leaf0 = [_]FCard{ smFile("a"), smFile("m") }; const leaf1 = [_]FCard{ smFile("b"), smFile("c") }; const nodes = [_]SmStore.Entry{ .{ .name = "n0", .view = .{ .files = &leaf0, .subs = &.{} } }, .{ .name = "n1", .view = .{ .files = &leaf1, .subs = &.{} } }, }; const subs = [_]SCard{ smSub("a", "n0"), smSub("b", "n1") }; try smExpectWalkError(error.OrderViolation, &subs, &nodes); // The step-3 check earns its place only by firing BEFORE the resolve: a // leaf's own step-4 ordering check would reject the same tree afterwards, // with the same error. §10 is what makes the difference load-bearing — // "the depth bound MUST be enforced ... or a crafted deep spine turns an // unauthenticated lookup endpoint into unbounded sequential artifact // reads" — so an out-of-order key must cost no artifact read at all. const gpa = std.testing.allocator; var st = SmStore{ .entries = &nodes }; var walk = TreeWalk.init(&subs, st.seam()); var out: std.ArrayList([]const u8) = .empty; defer out.deinit(gpa); while (true) { const f = walk.next() catch break; try out.append(gpa, (f orelse break).filename); } try std.testing.expectEqual(@as(usize, 2), out.items.len); // the first slice only try std.testing.expectEqual(@as(usize, 1), st.reads); // n1 was never read } test "submanifests §5.1 step 4: a leaf whose own F run does not ascend fails" { // §5.1 step 4: "every emitted decoded filename MUST compare strictly // greater than LAST, updating LAST as it is emitted." §4.5.2 makes a // descending F run unparseable, so no honest artifact reaches this check — // which is exactly why the walk must carry it rather than lean on the // parser: the resolver seam is an injection point, and §10 treats every // artifact behind it as attacker-suppliable. const leaf = [_]FCard{ smFile("a"), smFile("a") }; // equal, so not strictly greater const nodes = [_]SmStore.Entry{ .{ .name = "n0", .view = .{ .files = &leaf, .subs = &.{} } }, }; try smExpectWalkError(error.OrderViolation, &.{smSub("a", "n0")}, &nodes); } test "submanifests §5.2: a failed walk never yields a partial file set" { // §5.2: "An implementation MUST NOT present a partially resolved tree as // a complete file set." The first leaf resolves and the second does not, // so a partial-tolerating flatten would leave two of four files behind. const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, &.{sm_a2_1_leaf}); defer corpus.deinit(); var st = corpus.store(); var m = try parse(gpa, sm_a3_manifest); defer m.deinit(); try std.testing.expectError(error.SubUnresolvable, m.flattenTree(st.seam())); try std.testing.expect(m.flattened == null); } // --- §5.3 single-name lookup ------------------------------------------------ test "submanifests §5.3: single-name lookup against the A.3 tree" { // §A.5 acceptance vector: "single-name lookup (Section 5.3) against the // Appendix A.3 and A.4 trees, covering a present name in each leaf, an // absent name whose binary search descends into a leaf, and an absent // name sorting before the first S card." const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, &.{ sm_a2_1_leaf, sm_a2_2_leaf }); defer corpus.deinit(); var st = corpus.store(); var m = try parse(gpa, sm_a3_manifest); defer m.deinit(); for ([_][]const u8{ "README.md", "src/main.c", "src/node11.c", "src/util.c" }) |name| { const before = st.reads; const got = (try lookupTree(m.s, name, st.seam())).?; try std.testing.expectEqualStrings(name, got.filename); try std.testing.expect(got.hash != null); // §5.3: "at most one sub-manifest per tree level" -- this tree is one // level deep, so exactly one read answers the name. try std.testing.expectEqual(@as(usize, 1), st.reads - before); } // Absent, descending into the second leaf (src/node11.c <= src/other.c). try std.testing.expect((try lookupTree(m.s, "src/other.c", st.seam())) == null); // Absent, sorting before the first S card: no S card is <= it, so the // name is absent without reading any sub-manifest. const before = st.reads; try std.testing.expect((try lookupTree(m.s, "AAA.txt", st.seam())) == null); try std.testing.expectEqual(before, st.reads); } test "submanifests §5.3: single-name lookup against the A.4 tree" { const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, &.{ sm_a4_1_leaf, sm_a4_2_leaf, sm_a4_3_leaf }); defer corpus.deinit(); var st = corpus.store(); var m = try parse(gpa, sm_a4_manifest); defer m.deinit(); for (sm_a4_files) |f| { const got = (try lookupTree(m.s, f.name, st.seam())).?; try std.testing.expectEqualStrings(f.name, got.filename); } // Absent, descending into the third leaf; and absent before the first S. try std.testing.expect((try lookupTree(m.s, "f9999.c", st.seam())) == null); try std.testing.expect((try lookupTree(m.s, "a0000.c", st.seam())) == null); } test "submanifests §5.3: lookup reads at most 16 sub-manifests and fails past the bound" { // §5.3: "A lookup that would resolve a sub-manifest at depth greater than // 16 ... MUST fail hard; at most one sub-manifest per tree level, hence at // most 16 per lookup, is read." §10 makes the bound the defence against "a // crafted deep spine turn[ing] an unauthenticated lookup endpoint into // unbounded sequential artifact reads." var spine: SmSpine = .{}; const nodes = spine.build(16); // 16 interiors + a leaf = depth 17 var st = SmStore{ .entries = nodes }; const subs = [_]SCard{spine.root()}; try std.testing.expectError(error.DepthExceeded, lookupTree(&subs, "a", st.seam())); try std.testing.expectEqual(@as(usize, max_tree_depth), st.reads); // The legal boundary: 15 interiors plus the leaf is depth 16 and answers. var ok_spine: SmSpine = .{}; const ok_nodes = ok_spine.build(15); var ok_st = SmStore{ .entries = ok_nodes }; const ok_subs = [_]SCard{ok_spine.root()}; const got = (try lookupTree(&ok_subs, "a", ok_st.seam())).?; try std.testing.expectEqualStrings("a", got.filename); try std.testing.expectEqual(@as(usize, max_tree_depth), ok_st.reads); } test "submanifests §5.3: lookup fails hard on an unresolvable sub-manifest" { // §5.2's hard-failure discipline covers both entry points -- "beginning // the tree walk, and single-name lookup (Section 5.3)". var st = SmStore{ .entries = &.{} }; const subs = [_]SCard{smSub("a", "missing")}; try std.testing.expectError(error.SubUnresolvable, lookupTree(&subs, "a", st.seam())); } // --- §5.4 composition with delta manifests ---------------------------------- test "submanifests §5.4: a delta manifest over a tree baseline composes unchanged" { // §5.4: "the baseline cursor of the merge-walk of [FossilArtifact], // Section 8.2 is the enumeration of Section 5.1, and the merge-walk itself // is unchanged." §A.5 acceptance vector: "a delta manifest whose B card // names a tree manifest ... its R card verified over the composed // enumeration." const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, &.{ sm_a2_1_leaf, sm_a2_2_leaf }); defer corpus.deinit(); var st = corpus.store(); var base = try parse(gpa, sm_a3_manifest); defer base.deinit(); try base.flattenTree(st.seam()); // The delta replaces src/main.c and deletes src/util.c. const new_main = "int main(void){return 1;}\n"; var new_hash: [64]u8 = undefined; _ = artifactName(.sha3_256, new_main, &new_hash); const delta_files = [_]FCard{ .{ .filename = "src/main.c", .hash = &new_hash, .perm = null, .old_name = null, .mode = .regular }, .{ .filename = "src/util.c", .hash = null, .perm = null, .old_name = null, .mode = .regular }, }; var resolved = ManifestFiles{ .baseline_hash = null, .files = base.flattened.? }; var walk = try enumerate( .{ .baseline_hash = sm_a3_name, .files = &delta_files }, .{ .ctx = &resolved, .resolveFn = smFlatBaseline }, ); var tracked: std.ArrayList(TrackedFile) = .empty; defer tracked.deinit(gpa); while (walk.next()) |f| { if (f.hash == null) continue; // §4.7.6 delete marker const content = if (std.mem.eql(u8, f.filename, "src/main.c")) new_main else try smContentOf(&sm_a1_files, f.filename); try tracked.append(gpa, .{ .name = f.filename, .content = content }); } const want = [_][]const u8{ "README.md", "src/main.c", "src/node11.c" }; try std.testing.expectEqual(want.len, tracked.items.len); for (want, tracked.items) |w, got| try std.testing.expectEqualStrings(w, got.name); // §6.3.3 over the composed set, recomputed independently here from the // R-card input string §6.3.2 defines, then compared to the production // aggregate. const r_input = "README.md 10\n# Example\n" ++ "src/main.c 26\nint main(void){return 1;}\n" ++ "src/node11.c 13\n/* node11 */\n"; var md5: [16]u8 = undefined; std.crypto.hash.Md5.hash(r_input, &md5, .{}); const want_hex = std.fmt.bytesToHex(md5, .lower); var digest: [32]u8 = undefined; try std.testing.expectEqualStrings(&want_hex, aggregateChecksum(tracked.items, &digest)); } fn smFlatBaseline(ctx: *anyopaque, hash: []const u8) ?ManifestFiles { const self: *const ManifestFiles = @ptrCast(@alignCast(ctx)); std.debug.assert(std.mem.eql(u8, hash, sm_a3_name)); return self.*; } // --- §5.5 streaming R-card aggregate ---------------------------------------- test "submanifests §5.5: the streaming aggregate matches the whole-list one" { // §5.6 whole-tree verification computes the §6.3.2 input over a checkout, // one file at a time; the streaming aggregator is that shape, and it must // agree with `aggregateChecksum` byte for byte. var agg: af.ChecksumAggregator = .{}; var tracked: [sm_a1_files.len]TrackedFile = undefined; for (sm_a1_files, 0..) |f, i| { agg.update(f.name, f.content); tracked[i] = .{ .name = f.name, .content = f.content }; } var streamed: [32]u8 = undefined; var whole: [32]u8 = undefined; try std.testing.expectEqualStrings( aggregateChecksum(&tracked, &whole), agg.final(&streamed), ); // §A.3 states the value the tree's R card carries. try std.testing.expectEqualStrings("152bc688378069a3c22a1a4c55bc8b1a", &streamed); } // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 §6 -- zbits, canonical encoding, construction // --------------------------------------------------------------------------- test "submanifests §6.1: zbits is the leading zero bits of SHA3-256 over the decoded filename" { // §A.1 Table 3 and §A.4 Table 4 give the first four octets of PH(name) // alongside the count, so checking both pins the input bytes ("a decoded // filename ... with no terminator and no transformation") as well as the // count -- a digest over, say, a NUL-terminated name would miss both. const Row = struct { name: []const u8, prefix: []const u8, zb: u16 }; const rows = [_]Row{ // Table 3 .{ .name = "README.md", .prefix = "42d68a3e", .zb = 1 }, .{ .name = "src/main.c", .prefix = "ee622ca8", .zb = 0 }, .{ .name = "src/node11.c", .prefix = "009cb140", .zb = 8 }, .{ .name = "src/util.c", .prefix = "af9f5a2b", .zb = 0 }, // Table 4 .{ .name = "f1257.c", .prefix = "a8a4926e", .zb = 0 }, .{ .name = "f1346.c", .prefix = "47587030", .zb = 1 }, .{ .name = "f1523.c", .prefix = "19d00896", .zb = 3 }, .{ .name = "f2146.c", .prefix = "47aa4953", .zb = 1 }, .{ .name = "f2260.c", .prefix = "6a787dd2", .zb = 1 }, .{ .name = "f2468.c", .prefix = "6dccc191", .zb = 1 }, .{ .name = "f2614.c", .prefix = "00456123", .zb = 9 }, .{ .name = "f2954.c", .prefix = "f48ccb2e", .zb = 0 }, .{ .name = "f3514.c", .prefix = "85105080", .zb = 0 }, }; for (rows) |row| { var buf: [64]u8 = undefined; const digest = artifactName(.sha3_256, row.name, &buf); try std.testing.expectEqualStrings(row.prefix, digest[0..8]); try std.testing.expectEqual(row.zb, zbits(row.name)); } } test "submanifests §6.2: the canonical encoding escapes exactly SP, LF and backslash" { // "exactly SP (0x20) as \s, LF (0x0A) as \n, and backslash (0x5C) as \; // every other octet, including the decoder's five other escape octets TAB, // CR, VT, FF, and NUL ..., is emitted raw, and escaping it is // non-canonical." var out: [64]u8 = undefined; try std.testing.expectEqualStrings("a\\sb", encodeCanonical("a b", &out)); try std.testing.expectEqualStrings("a\\nb", encodeCanonical("a\nb", &out)); try std.testing.expectEqualStrings("a\\\\b", encodeCanonical("a\\b", &out)); // The five octets §4.3 escapes but §6.2 does not. Each one is exactly where // `encodeEscapes` and `encodeCanonical` must disagree. const raw = [_]u8{ 0x09, 0x0D, 0x0B, 0x0C, 0x00 }; const escaped = [_][]const u8{ "\\t", "\\r", "\\v", "\\f", "\\0" }; for (raw, escaped) |c, esc| { const token = [_]u8{ 'a', c, 'b' }; try std.testing.expectEqualStrings(&token, encodeCanonical(&token, &out)); var out2: [64]u8 = undefined; const want = try std.fmt.allocPrint(std.testing.allocator, "a{s}b", .{esc}); defer std.testing.allocator.free(want); try std.testing.expectEqualStrings(want, encodeEscapes(&token, &out2)); } } test "artifact §4.3: create escapes every check-in token under §4.3, never the §6.2 canonical spelling" { // The test above pins the two ENCODERS apart. This one pins which encoder // the EMISSION PATH calls, which is the claim the compatibility contract // actually rests on: submanifests §6.2's canonical encoding reaches exactly // the construction's own output (S card filenames, leaf F filename and // old-name) and NOT one position `create` emits. Rerouting any check-in // card through it respells every token containing TAB, CR, VT, FF or NUL // and so renames each stored artifact carrying one -- a silent break of // every existing check-in that no Appendix vector can catch, because no // vector uses those octets. const gpa = std.testing.allocator; // All five octets §4.3 escapes and §6.2 leaves raw, plus SP and backslash, // in one filename: `\t \r \v \f \0 \s \\` under §4.3 Table 1. const wide_name = "a\tb\rc\x0bd\x0ce\x00f g\\h.c"; const wide_old = "o\tp\\q.c"; const f_cards = [_]FCard{.{ .filename = wide_name, .hash = "9" ** 64, .perm = "w", .old_name = wide_old, .mode = .regular, }}; const t_cards = [_]af.TCard{.{ .sign = .add, .name = "t\tname", .target = "*", .value = "t\tvalue", }}; var m: Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = "wide\tescapes" }, .d = .{ .timestamp = "2026-07-26T00:00:00" }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = null, .u = .{ .login = "u\tser" }, .z = null, .w = null, .f = &f_cards, .j = &.{}, .m = &.{}, .p = &.{}, .q = &.{}, .s = &.{}, .t = &t_cards, }; defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); // Positive: the §4.3 spelling, in each position `appendEscaped` reaches. try std.testing.expect(std.mem.startsWith(u8, bytes, "C wide\\tescapes\n")); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nF a\\tb\\rc\\vd\\fe\\0f\\sg\\\\h.c ") != null); try std.testing.expect(std.mem.indexOf(u8, bytes, " w o\\tp\\\\q.c\n") != null); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nT +t\\tname * t\\tvalue\n") != null); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nU u\\tser\n") != null); // Negative, and the assertion that catches a reroute anywhere at all: under // §4.3 not one of the five octets survives into the artifact. Under §6.2's // canonical encoding every one of them would appear raw. for ([_]u8{ 0x09, 0x0D, 0x0B, 0x0C, 0x00 }) |c| { try std.testing.expect(std.mem.indexOfScalar(u8, bytes, c) == null); } // And it survives the round trip: the decoded tokens are the originals. var back = try parse(gpa, bytes); defer back.deinit(); try std.testing.expectEqualStrings(wide_name, back.f[0].filename); try std.testing.expectEqualStrings(wide_old, back.f[0].old_name.?); try std.testing.expectEqualStrings("wide\tescapes", back.c.?.comment); } test "submanifests §6.2: decoding a canonical encoding restores the decoded name exactly" { // "Decoding a canonical encoding restores the decoded name exactly." const names = [_][]const u8{ "README.md", "a b.c", "x\\y.c", "a\nb", "tab\there", "\x00\x0b\x0c\r", "\\\\ \\ ", "dir/sub dir/f\\g", }; for (names) |name| { var enc_buf: [128]u8 = undefined; var dec_buf: [128]u8 = undefined; const enc = encodeCanonical(name, &enc_buf); try std.testing.expectEqualStrings(name, decodeEscapes(enc, &dec_buf)); } } /// §6.2's construction input: "the entries, identical tokens included, that a /// flat baseline manifest would emit as F cards". Initialized in place, never /// returned by value -- each card's `hash` borrows this struct's own storage, /// so a move would leave every hash pointing at the dead copy. const SmEntrySet = struct { hashes: [16][64]u8 = undefined, cards: [16]FCard = undefined, n: usize = 0, fn init(self: *SmEntrySet, files: []const SmFile) void { std.debug.assert(files.len <= self.cards.len); self.n = files.len; for (files, 0..) |f, i| { // §7 hash policy: the Appendix A vectors are named under sha3. _ = artifactName(.sha3_256, f.content, &self.hashes[i]); self.cards[i] = .{ .filename = f.name, .hash = &self.hashes[i], .perm = null, .old_name = null, .mode = .regular, }; } } fn slice(self: *const SmEntrySet) []const FCard { return self.cards[0..self.n]; } }; /// The §6.3.2 aggregate over a fixture file list, in filename order -- the R /// card a tree manifest carries per §5.5. fn smRCard(files: []const SmFile, out: *[32]u8) []u8 { var agg: af.ChecksumAggregator = .{}; for (files) |f| agg.update(f.name, f.content); return agg.final(out); } /// A tree manifest built from a constructed S run, exactly as a writer would /// assemble it: every card but the S run comes from the check-in, and the S run /// comes from `buildTree`. fn smTreeManifest( gpa: std.mem.Allocator, comment: []const u8, timestamp: []const u8, login: []const u8, r: []const u8, s: []const SCard, ) !Manifest { return .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = comment }, .d = .{ .timestamp = timestamp }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = .{ .checksum = r }, .u = .{ .login = login }, .z = null, .w = null, .f = &.{}, .j = &.{}, .m = &.{}, .p = &.{}, .q = &.{}, .s = s, .t = &.{}, }; } test "submanifests §A.1/§A.2: the construction reproduces both leaf vectors byte-for-byte at k = 3" { // §A.1: "Only src/node11.c qualifies as a boundary (zbits 8 >= k), tested // while the open group holds 2 >= MIN entries, so the scan of Section 6.2, // step 1 yields two leaves: (README.md, src/main.c) and (src/node11.c, // src/util.c). ... The final-group merge fires at neither height." var es: SmEntrySet = .{}; es.init(&sm_a1_files); var tb = try buildTree(std.testing.allocator, es.slice(), 3, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(@as(usize, 2), tb.subs.len); try std.testing.expectEqualStrings(sm_a2_1_leaf, tb.subs[0].bytes); try std.testing.expectEqualStrings(sm_a2_1_name, tb.subs[0].name); try std.testing.expectEqualStrings(sm_a2_2_leaf, tb.subs[1].bytes); try std.testing.expectEqualStrings(sm_a2_2_name, tb.subs[1].name); // §A.1: "that scan yields a single group, so step 3 terminates and the // manifest carries the two S cards directly." try std.testing.expectEqual(@as(usize, 2), tb.s.len); try std.testing.expectEqualStrings("README.md", tb.s[0].filename); try std.testing.expectEqualStrings(sm_a2_1_name, tb.s[0].hash); try std.testing.expectEqualStrings("src/node11.c", tb.s[1].filename); try std.testing.expectEqualStrings(sm_a2_2_name, tb.s[1].hash); } test "submanifests §A.3: the writer serializes the tree manifest byte-for-byte" { const gpa = std.testing.allocator; var es: SmEntrySet = .{}; es.init(&sm_a1_files); var tb = try buildTree(gpa, es.slice(), 3, .sha3_256); defer tb.deinit(); // §5.5: the R card is the §6.3.2 aggregate over the enumeration, which for // this tree is the same four files a flat manifest would carry. var r: [32]u8 = undefined; try std.testing.expectEqualStrings("152bc688378069a3c22a1a4c55bc8b1a", smRCard(&sm_a1_files, &r)); var m = try smTreeManifest(gpa, "Tree manifest example", "2026-07-26T00:00:00", "drh", &r, tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expectEqualStrings(sm_a3_manifest, bytes); var name_buf: [64]u8 = undefined; try std.testing.expectEqualStrings(sm_a3_name, artifactName(.sha3_256, bytes, &name_buf)); } test "submanifests §A.4: the interior-height merge reproduces the three leaves and the manifest" { // The discriminator vector: "applying the final-group merge at every // height, which is the rule, versus applying it at height 0 only. On this // nine-name check-in the readings produce different trees." A writer that // merges only at height 0 emits two interior sub-manifests and a // two-S-card manifest, so every assertion below moves. const gpa = std.testing.allocator; var es: SmEntrySet = .{}; es.init(&sm_a4_files); var tb = try buildTree(gpa, es.slice(), 3, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); // Three leaves and NOTHING above them: the height-1 merge collapsed the // scan to a single group, so no interior sub-manifest exists. try std.testing.expectEqual(@as(usize, 3), tb.subs.len); try std.testing.expectEqualStrings(sm_a4_1_leaf, tb.subs[0].bytes); try std.testing.expectEqualStrings(sm_a4_1_name, tb.subs[0].name); try std.testing.expectEqualStrings(sm_a4_2_leaf, tb.subs[1].bytes); try std.testing.expectEqualStrings(sm_a4_2_name, tb.subs[1].name); try std.testing.expectEqualStrings(sm_a4_3_leaf, tb.subs[2].bytes); try std.testing.expectEqualStrings(sm_a4_3_name, tb.subs[2].name); try std.testing.expectEqual(@as(usize, 3), tb.s.len); try std.testing.expectEqualStrings("f1257.c", tb.s[0].filename); try std.testing.expectEqualStrings("f1523.c", tb.s[1].filename); try std.testing.expectEqualStrings("f2614.c", tb.s[2].filename); var r: [32]u8 = undefined; try std.testing.expectEqualStrings("c1155756fd2fa431f9c663496775f8e2", smRCard(&sm_a4_files, &r)); var m = try smTreeManifest(gpa, "Interior merge example", "2026-07-27T00:00:00", "drh", &r, tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expectEqualStrings(sm_a4_manifest, bytes); var name_buf: [64]u8 = undefined; try std.testing.expectEqualStrings(sm_a4_name, artifactName(.sha3_256, bytes, &name_buf)); } test "submanifests §6.2 step 2: fewer than two groups emits a flat manifest" { const gpa = std.testing.allocator; // §A.1: "At k = 4, MIN = 4 suppresses the only qualifying boundary and step // 2 emits a flat manifest." §A.5 names this vector: "a qualifying natural // boundary suppressed by the MIN limit, with flat canonical output". var es: SmEntrySet = .{}; es.init(&sm_a1_files); var flat_k4 = try buildTree(gpa, es.slice(), 4, .sha3_256); defer flat_k4.deinit(); try std.testing.expect(flat_k4.isFlat()); // "the case N = 0 (an empty check-in, which Fossil permits) yields zero // groups and a flat manifest carrying no F cards and no S cards", and N = 1 // yields one group -- both take the fewer-than-two exit (§A.5). var empty = try buildTree(gpa, &.{}, 3, .sha3_256); defer empty.deinit(); try std.testing.expect(empty.isFlat()); try std.testing.expectEqual(@as(usize, 0), empty.subs.len); var one: SmEntrySet = .{}; one.init(sm_a1_files[0..1]); var single = try buildTree(gpa, one.slice(), 3, .sha3_256); defer single.deinit(); try std.testing.expect(single.isFlat()); // A flat build is inert on the manifest: an empty S run adds no byte, so // every check-in this writer already produced serializes identically. var m = try smTreeManifest(gpa, "flat", "2026-07-26T00:00:00", "drh", "0" ** 32, flat_k4.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nS ") == null); try std.testing.expect(!std.mem.startsWith(u8, bytes, "S ")); } test "submanifests §6.2: the construction is deterministic across repeated builds" { // "two conforming writers MUST produce byte-identical artifacts for the // same check-in" -- so two runs of one writer certainly must. // // What this test does and does NOT reach. It catches per-build state: an // uninitialized buffer read, a static scratch not reset between calls, a // level array reused across builds. It does NOT catch a dependence on // hashmap iteration order -- Zig's `StringHashMap` hashes with a fixed // Wyhash seed, so identical insertion sequences iterate identically and a // hashmap in the pipeline would repeat-build byte-identically. The guard // against that is structural and stated at the top of artifact.zig's §6 // section (no unordered container appears anywhere in the construction); // the byte-identity vectors of §A.2 and §A.4, and the pinned build below, // are what would actually move if one were introduced. const gpa = std.testing.allocator; inline for (.{ &sm_a1_files, &sm_a4_files }) |files| { inline for (.{ 1, 2, 3, 5, 12 }) |k| { var es: SmEntrySet = .{}; es.init(files); var first = try buildTree(gpa, es.slice(), k, .sha3_256); defer first.deinit(); var second = try buildTree(gpa, es.slice(), k, .sha3_256); defer second.deinit(); try std.testing.expectEqual(first.isFlat(), second.isFlat()); try std.testing.expectEqual(first.subs.len, second.subs.len); for (first.subs, second.subs) |a_sub, b_sub| { try std.testing.expectEqualStrings(a_sub.bytes, b_sub.bytes); try std.testing.expectEqualStrings(a_sub.name, b_sub.name); } try std.testing.expectEqual(first.s.len, second.s.len); for (first.s, second.s) |a_s, b_s| { try std.testing.expectEqualStrings(a_s.filename, b_s.filename); try std.testing.expectEqualStrings(a_s.hash, b_s.hash); } } } } test "submanifests §6.2: a multi-level build reproduces its pinned artifact names" { // The cross-process half of §6.2's byte-identity obligation: repeat-building // in one process cannot see state that is stable within a process (hashmap // iteration order, a pointer-derived tiebreak), so the names below are // written down instead. Sixty ascending names at k = 1 build four levels, // the deepest shape the suite carries, and every artifact of that tree is a // pure function of the name set, k and the hash policy -- so any drift in // partitioning, ordering, encoding or serialization moves one of them. // // A CHANGE DETECTOR, not a spec oracle. The oracle is Appendix A; these // values were taken from a build the §A.1/§A.2/§A.3/§A.4 vectors already // certify, and a diff here means "the construction moved", not "the // construction is wrong". Re-derive from the draft before re-pinning. const gpa = std.testing.allocator; var set = try SmNameSet.init(gpa, 0, 60); defer set.deinit(); var tb = try buildTree(gpa, set.entries, 1, .sha3_256); defer tb.deinit(); try std.testing.expectEqual(@as(usize, 62), tb.subs.len); try std.testing.expectEqual(@as(usize, 2), tb.s.len); // The manifest's own S run: the top level, keys and child names. try std.testing.expectEqualStrings("p0000.c", tb.s[0].filename); try std.testing.expectEqualStrings( "f09828dbcfeb4ad7071ed2f2c6219c75cdb9bdb95497fe81254c2532b7584221", tb.s[0].hash, ); try std.testing.expectEqualStrings("p0030.c", tb.s[1].filename); try std.testing.expectEqualStrings( "4e2c784143015abbeaf1c74c202e6358a8b17d7a094f2801ce436b6f14ad0533", tb.s[1].hash, ); // The first leaf and the last artifact built -- the two ends of the storage // order §6.2 requires. try std.testing.expectEqualStrings( "7047a44c352309e2118b536da2756338c9f35fbe98cb09c79215552d1bac9016", tb.subs[0].name, ); try std.testing.expectEqualStrings( "4e2c784143015abbeaf1c74c202e6358a8b17d7a094f2801ce436b6f14ad0533", tb.subs[tb.subs.len - 1].name, ); } /// §A.5: "a tree whose filenames exercise the canonical encoding of Section 6.2 /// -- a name containing SP and a name containing a backslash", extended with /// one name per octet §4.3 escapes but §6.2 leaves RAW, which is where a writer /// that reached for `encodeEscapes` would be caught. Names are one per leading /// letter, so the list is ascending under the canonical comparator by /// inspection; at k = 3 (MIN 2, MAX 32) the height-0 scan puts boundaries at /// `g<VT>h.c` (zbits 4 >= k) and `m n.c` (zbits 3 >= k), each tested with 3 >= /// MIN entries open, giving leaves of 3, 3 and 4. const sm_esc_files = [_]SmFile{ .{ .name = "a b.c", .content = "0\n" }, // SP -- escaped .{ .name = "c\td.c", .content = "1\n" }, // TAB -- raw .{ .name = "e\rf.c", .content = "2\n" }, // CR -- raw .{ .name = "g\x0bh.c", .content = "3\n" }, // VT -- raw .{ .name = "i\x0cj.c", .content = "4\n" }, // FF -- raw .{ .name = "k\x00l.c", .content = "5\n" }, // NUL -- raw .{ .name = "m n.c", .content = "6\n" }, // SP -- escaped .{ .name = "q\\r.c", .content = "7\n" }, // \ -- escaped .{ .name = "x\\y.c", .content = "8\n" }, .{ .name = "z\\w.c", .content = "9\n" }, }; test "submanifests §6.2: construction emits filenames in the canonical encoding only" { const gpa = std.testing.allocator; var es: SmEntrySet = .{}; es.init(&sm_esc_files); var tb = try buildTree(gpa, es.slice(), 3, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(@as(usize, 3), tb.subs.len); try std.testing.expectEqual(@as(usize, 3), tb.s.len); // The three escaped octets, in leaf F card position. try std.testing.expect(std.mem.startsWith(u8, tb.subs[0].bytes, "F a\\sb.c ")); try std.testing.expect(std.mem.indexOf(u8, tb.subs[2].bytes, "F m\\sn.c ") != null); try std.testing.expect(std.mem.indexOf(u8, tb.subs[2].bytes, "\nF q\\\\r.c ") != null); // The five §4.3 octets §6.2 leaves raw: "escaping it is non-canonical". try std.testing.expect(std.mem.indexOf(u8, tb.subs[0].bytes, "\nF c\td.c ") != null); try std.testing.expect(std.mem.indexOf(u8, tb.subs[0].bytes, "\nF e\rf.c ") != null); try std.testing.expect(std.mem.startsWith(u8, tb.subs[1].bytes, "F g\x0bh.c ")); try std.testing.expect(std.mem.indexOf(u8, tb.subs[1].bytes, "\nF i\x0cj.c ") != null); try std.testing.expect(std.mem.indexOf(u8, tb.subs[1].bytes, "\nF k\x00l.c ") != null); // §6.2: the S card filename is the canonical encoding of the child's key -- // the same rule in the other emitting position. var m = try smTreeManifest(gpa, "escapes", "2026-07-26T00:00:00", "drh", "0" ** 32, tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nS a\\sb.c ") != null); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nS g\x0bh.c ") != null); try std.testing.expect(std.mem.indexOf(u8, bytes, "\nS m\\sn.c ") != null); // And it all decodes back: parse each artifact and compare the decoded // names against the originals ("Decoding a canonical encoding restores the // decoded name exactly"). var decoded: std.ArrayList([]const u8) = .empty; defer decoded.deinit(gpa); var leaves: std.ArrayList(Manifest) = .empty; defer { for (leaves.items) |*leaf| leaf.deinit(); leaves.deinit(gpa); } for (tb.subs) |sub| { try leaves.append(gpa, try parse(gpa, sub.bytes)); const leaf = &leaves.items[leaves.items.len - 1]; try std.testing.expectEqual(ArtifactType.submanifest, leaf.kind); for (leaf.f) |fc| try decoded.append(gpa, fc.filename); } try std.testing.expectEqual(sm_esc_files.len, decoded.items.len); for (sm_esc_files, decoded.items) |want, got| try std.testing.expectEqualStrings(want.name, got); var tm = try parse(gpa, bytes); defer tm.deinit(); try std.testing.expectEqualStrings("a b.c", tm.s[0].filename); try std.testing.expectEqualStrings("g\x0bh.c", tm.s[1].filename); try std.testing.expectEqualStrings("m n.c", tm.s[2].filename); } test "submanifests §6.2: a constructed tree walks back to the entries it partitioned" { // The writer/reader join: §5.1's walk over what §6.2 built must reproduce // the input list exactly, at every supported k. This is the in-process form // of the flat-vs-tree differential the simulation lane sweeps. const gpa = std.testing.allocator; inline for (.{ &sm_a1_files, &sm_a4_files, &sm_esc_files }) |files| { var k: u4 = af.min_k; while (k <= af.max_k) : (k += 1) { var es: SmEntrySet = .{}; es.init(files); var tb = try buildTree(gpa, es.slice(), k, .sha3_256); defer tb.deinit(); if (tb.isFlat()) continue; // `SmCorpus` parses out of the list and retains nothing of it, so // the list itself is freed as soon as the corpus is built. const list = try gpa.alloc([]const u8, tb.subs.len); for (tb.subs, list) |sub, *slot| slot.* = sub.bytes; var corpus = SmCorpus.init(gpa, list) catch |e| { gpa.free(list); return e; }; gpa.free(list); defer corpus.deinit(); var st = corpus.store(); var r: [32]u8 = undefined; var m = try smTreeManifest(gpa, "walkback", "2026-07-26T00:00:00", "drh", smRCard(files, &r), tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); var parsed = try parse(gpa, bytes); defer parsed.deinit(); try parsed.flattenTree(st.seam()); const flat = parsed.flattened.?; try std.testing.expectEqual(files.len, flat.len); for (files.*, flat) |want, got| try std.testing.expectEqualStrings(want.name, got.filename); } } } /// A generated ascending name set, `p0000.c` upward. Fixed-width digits make /// numeric and lexical order coincide, so the list is sorted by construction. const SmNameSet = struct { arena: std.heap.ArenaAllocator, files: []SmFile, entries: []FCard, fn init(gpa: std.mem.Allocator, start: usize, count: usize) !SmNameSet { var arena = std.heap.ArenaAllocator.init(gpa); errdefer arena.deinit(); const a = arena.allocator(); const files = try a.alloc(SmFile, count); const entries = try a.alloc(FCard, count); for (files, entries, 0..) |*f, *e, off| { const i = start + off; f.* = .{ .name = try std.fmt.allocPrint(a, "p{d:0>4}.c", .{i}), .content = try std.fmt.allocPrint(a, "/* {d:0>4} */\n", .{i}), }; const hash = try a.alloc(u8, 64); _ = artifactName(.sha3_256, f.content, hash[0..64]); e.* = .{ .filename = f.name, .hash = hash, .perm = null, .old_name = null, .mode = .regular }; } return .{ .arena = arena, .files = files, .entries = entries }; } fn deinit(self: *SmNameSet) void { self.arena.deinit(); } }; /// The bytes of the constructed sub-manifest named `hash`. A miss is a broken /// build result, not a test-fixture problem, so it panics rather than skipping. fn smSubBytes(tb: *const TreeBuild, hash: []const u8) []const u8 { for (tb.subs) |sub| { if (std.mem.eql(u8, sub.name, hash)) return sub.bytes; } std.debug.panic("constructed S card references an artifact the build did not emit", .{}); } test "submanifests §6.2 step 3: the interior scan raises the threshold with the height" { // §6.1/§6.2 step 3: "a boundary between height-h nodes requires zbits(key) // >= (h+1)*k" -- k at height 0, 2k at height 1, and so on. The Appendix A // vectors cannot separate that from a threshold of h*k: both of their // height-1 scans are decided by MIN, not by the threshold. Sixty names do // separate it, so these shapes are the regression that holds the formula. // A writer using h*k builds 10 sub-manifests under a 3-card S run at k = 3 // (an interior level the rule does not create) and 35 leaves under a // 35-card S run at k = 1. const gpa = std.testing.allocator; var set = try SmNameSet.init(gpa, 0, 60); defer set.deinit(); const Row = struct { k: u4, subs: usize, s: usize, levels: usize }; for ([_]Row{ .{ .k = 3, .subs = 7, .s = 7, .levels = 1 }, // leaves only .{ .k = 2, .subs = 18, .s = 2, .levels = 2 }, // leaves + one interior level .{ .k = 1, .subs = 62, .s = 2, .levels = 4 }, // four levels, still inside 16 }) |row| { var tb = try buildTree(gpa, set.entries, row.k, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(row.subs, tb.subs.len); try std.testing.expectEqual(row.s, tb.s.len); // §6.2: "Sub-manifest artifacts are stored before the manifest that // references them" -- the list order is the storage order, so every // hash an artifact references must already have appeared. Counting the // distinct levels on the way also confirms the height the shape claims. var seen: std.ArrayList([]const u8) = .empty; defer seen.deinit(gpa); var leaves_seen: usize = 0; for (tb.subs) |sub| { var m = try parse(gpa, sub.bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.submanifest, m.kind); if (m.f.len != 0) leaves_seen += 1; for (m.s) |sc| { var found = false; for (seen.items) |prior| found = found or std.mem.eql(u8, prior, sc.hash); try std.testing.expect(found); // referenced before stored ⇒ fail } try seen.append(gpa, sub.name); } try std.testing.expect(leaves_seen >= 2); // Descend from the manifest's S run one level at a time. Every node at // a given level has the same height by construction, so probing the // first is enough to decide whether another level exists. var level: []const SCard = tb.s; var depth: usize = 0; var carrier: ?Manifest = null; defer if (carrier) |*c| c.deinit(); while (true) { depth += 1; var probe = try parse(gpa, smSubBytes(&tb, level[0].hash)); if (probe.f.len != 0) { probe.deinit(); break; // a leaf: this level is height 0 } if (carrier) |*c| c.deinit(); carrier = probe; level = carrier.?.s; // the retained copy, not the one just moved from try std.testing.expect(depth < af.max_construct_height); } try std.testing.expectEqual(row.levels, depth); } } test "submanifests §6.2 step 1: the final-group merge is capped by MAX" { // "if the final group holds fewer than MIN members, a preceding group // exists, AND THE TWO TOGETHER HOLD AT MOST MAX MEMBERS, the final group is // merged into the group preceding it." At k = 3 (MIN 2, MAX 32) the // 33 names p0152.c..p0184.c scan to a MAX-forced boundary at p0184.c -- // no name in between qualifies -- leaving a final group of 1 < MIN whose // predecessor already holds MAX. 1 + 32 = 33 > MAX, so the merge is // suppressed and the tree keeps both leaves. A writer that merged anyway // would collapse the scan to one group and emit a flat manifest instead. const gpa = std.testing.allocator; var set = try SmNameSet.init(gpa, 152, 33); defer set.deinit(); var tb = try buildTree(gpa, set.entries, 3, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(@as(usize, 2), tb.subs.len); try std.testing.expectEqual(@as(usize, 2), tb.s.len); try std.testing.expectEqualStrings("p0152.c", tb.s[0].filename); try std.testing.expectEqualStrings("p0184.c", tb.s[1].filename); var first = try parse(gpa, tb.subs[0].bytes); defer first.deinit(); try std.testing.expectEqual(@as(usize, 32), first.f.len); // exactly MAX var second = try parse(gpa, tb.subs[1].bytes); defer second.deinit(); try std.testing.expectEqual(@as(usize, 1), second.f.len); } // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 §A.5 -- the authored half of the required // vector set. Appendix A supplies seven artifacts; §A.5 requires four more // shapes it marks "(to author)", and those eleven artifacts live here. // // Every claim these tests make about a vector -- artifact name, Z card, R card, // and the §6.2 partition that produced the two constructed trees -- was // recomputed off the emitted bytes by a separate implementation of §6.1, §6.2 // and §6.3.2 written against the draft text, sharing no code with zit. The // vectors are generated by the construction and checked by something that is // not the construction; neither half alone would catch a systematic error. // --------------------------------------------------------------------------- /// The five-name check-in of the height-0 final-group-merge vector, named in /// the §A.4 style. At k = 3 (MIN 2, MAX 32) the scan places a boundary at /// f0006.c (zbits 3 >= k, 2 >= MIN open) and again at f0016.c (zbits 3 >= k, /// 2 >= MIN open), so the final group holds 1 < MIN; a preceding group of 2 /// exists and 1 + 2 <= MAX, so the merge fires and the tree keeps two leaves of /// 2 and 3. A writer that skipped the height-0 merge would emit three leaves /// under a three-card S run -- different artifacts, different check-in hash. const sm_a5_merge_files = [_]SmFile{ .{ .name = "f0004.c", .content = "/* 0004 */\n" }, .{ .name = "f0005.c", .content = "/* 0005 */\n" }, .{ .name = "f0006.c", .content = "/* 0006 */\n" }, .{ .name = "f0007.c", .content = "/* 0007 */\n" }, .{ .name = "f0016.c", .content = "/* 0016 */\n" }, }; /// The nine-name check-in of the canonical-encoding vector: §A.5's "a name /// containing SP and a name containing a backslash", alternating so that both /// octets land in leaf F cards AND one of each lands in an S card key. The two /// spellings of one index are distinct names and sort SP-first (0x20 < 0x5C). /// /// This is the byte-identity half of the encoding requirement. `sm_esc_files` /// above is the other half: it adds the five octets §4.3 escapes and §6.2 /// leaves raw, which cannot appear in a checked-in vector file, so it stays a /// generated fixture. const sm_a5_enc_files = [_]SmFile{ .{ .name = "v00 w.c", .content = "v00 w.c\n" }, .{ .name = "v00\\w.c", .content = "v00\\w.c\n" }, .{ .name = "v01 w.c", .content = "v01 w.c\n" }, .{ .name = "v01\\w.c", .content = "v01\\w.c\n" }, .{ .name = "v02 w.c", .content = "v02 w.c\n" }, .{ .name = "v02\\w.c", .content = "v02\\w.c\n" }, .{ .name = "v03 w.c", .content = "v03 w.c\n" }, .{ .name = "v03\\w.c", .content = "v03\\w.c\n" }, .{ .name = "v04 w.c", .content = "v04 w.c\n" }, }; test "submanifests §A.5: every authored vector is named by the SHA3-256 recorded here" { // The same obligation the Appendix A preamble puts on its own artifacts -- // "every artifact name, checksum, and byte-count claim recomputes from the // exact displayed byte sequence" -- applied to the authored eleven. The Z // check is what makes a truncated or re-wrapped vector file fail loudly // rather than silently changing what the corpus tests. const Row = struct { bytes: []const u8, len: usize, name: []const u8, kind: ArtifactType }; const rows = [_]Row{ .{ .bytes = sm_a5_3lvl_int_1, .len = 185, .name = sm_a5_3lvl_int_1_name, .kind = .submanifest }, .{ .bytes = sm_a5_3lvl_int_2, .len = 110, .name = sm_a5_3lvl_int_2_name, .kind = .submanifest }, .{ .bytes = sm_a5_3lvl_manifest, .len = 272, .name = sm_a5_3lvl_manifest_name, .kind = .manifest }, .{ .bytes = sm_a5_merge_1_leaf, .len = 185, .name = sm_a5_merge_1_name, .kind = .submanifest }, .{ .bytes = sm_a5_merge_2_leaf, .len = 260, .name = sm_a5_merge_2_name, .kind = .submanifest }, .{ .bytes = sm_a5_merge_manifest, .len = 279, .name = sm_a5_merge_manifest_name, .kind = .manifest }, .{ .bytes = sm_a5_enc_1_leaf, .len = 339, .name = sm_a5_enc_1_name, .kind = .submanifest }, .{ .bytes = sm_a5_enc_2_leaf, .len = 263, .name = sm_a5_enc_2_name, .kind = .submanifest }, .{ .bytes = sm_a5_enc_3_leaf, .len = 187, .name = sm_a5_enc_3_name, .kind = .submanifest }, .{ .bytes = sm_a5_enc_manifest, .len = 357, .name = sm_a5_enc_manifest_name, .kind = .manifest }, .{ .bytes = sm_a5_delta_manifest, .len = 289, .name = sm_a5_delta_manifest_name, .kind = .manifest }, }; for (rows) |row| { try std.testing.expectEqual(row.len, row.bytes.len); var out: [64]u8 = undefined; try std.testing.expectEqualStrings(row.name, artifactName(.sha3_256, row.bytes, &out)); try std.testing.expect(verify(row.name, row.bytes)); try std.testing.expectEqual(ZVerdict.match, verifyZCard(row.bytes)); var m = try parse(std.testing.allocator, row.bytes); defer m.deinit(); try std.testing.expectEqual(row.kind, m.kind); } } test "submanifests §4.3/§A.5: an interior sub-manifest is a SUBMANIFEST carrying only S cards" { // The shape no published vector had. §4.3 reassigns it -- S sets MANIFEST // and no D card is present -- and §4.4's "F or S, never both, never // neither" is satisfied by the S side, which until this vector existed had // no artifact-level witness at all: every sub-manifest in Appendix A is a // leaf. for ([_][]const u8{ sm_a5_3lvl_int_1, sm_a5_3lvl_int_2 }) |bytes| { var m = try parse(std.testing.allocator, bytes); defer m.deinit(); try std.testing.expectEqual(ArtifactType.submanifest, m.kind); try std.testing.expectEqual(@as(usize, 0), m.f.len); try std.testing.expect(m.s.len >= 1); } var upper = try parse(std.testing.allocator, sm_a5_3lvl_int_1); defer upper.deinit(); try std.testing.expectEqual(@as(usize, 2), upper.s.len); try std.testing.expectEqualStrings("f1257.c", upper.s[0].filename); try std.testing.expectEqualStrings(sm_a4_1_name, upper.s[0].hash); try std.testing.expectEqualStrings("f1523.c", upper.s[1].filename); try std.testing.expectEqualStrings(sm_a4_2_name, upper.s[1].hash); var lower = try parse(std.testing.allocator, sm_a5_3lvl_int_2); defer lower.deinit(); try std.testing.expectEqual(@as(usize, 1), lower.s.len); try std.testing.expectEqualStrings("f2614.c", lower.s[0].filename); try std.testing.expectEqualStrings(sm_a4_3_name, lower.s[0].hash); } test "submanifests §A.4/§A.5: the three-level vector is the tree the height-0-only reading builds" { // §A.4: "A writer that merges only at height 0 keeps the two height-1 // groups, emits two interior sub-manifests, and produces a two-S-card // manifest -- different artifact bytes and a different check-in hash for // the identical file set." The three-level vector IS that tree, so the two // halves of §A.4's claim become one assertion pair: the construction // reaches the A.4 manifest and never these bytes, and both trees enumerate // the same nine files. const gpa = std.testing.allocator; var es: SmEntrySet = .{}; es.init(&sm_a4_files); var tb = try buildTree(gpa, es.slice(), 3, .sha3_256); defer tb.deinit(); // Not one artifact of the height-0-only tree is reachable from the rule. for (tb.subs) |sub| { try std.testing.expect(!std.mem.eql(u8, sub.name, sm_a5_3lvl_int_1_name)); try std.testing.expect(!std.mem.eql(u8, sub.name, sm_a5_3lvl_int_2_name)); } var r: [32]u8 = undefined; var m = try smTreeManifest(gpa, "Interior merge example", "2026-07-27T00:00:00", "drh", smRCard(&sm_a4_files, &r), tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expectEqualStrings(sm_a4_manifest, bytes); try std.testing.expect(!std.mem.eql(u8, sm_a4_manifest, sm_a5_3lvl_manifest)); // Same file set, same R card: §5.5 reads the enumeration, and the two trees // enumerate identically. A reader "MUST NOT reject an artifact because its // partition differs from the canonical construction" (§6), which is exactly // the property the walk test above exercises on these bytes. var canonical = try parse(gpa, sm_a4_manifest); defer canonical.deinit(); var alternate = try parse(gpa, sm_a5_3lvl_manifest); defer alternate.deinit(); try std.testing.expectEqualStrings(canonical.r.?.checksum, alternate.r.?.checksum); } test "submanifests §6.2 step 1/§A.5: the height-0 final-group merge reproduces its vectors" { // §A.5 required vector: "a scan ending in a group shorter than MIN with a // predecessor able to absorb it within MAX, confirming the height-0 // final-group merge. (The interior-height counterpart is Appendix A.4.)" // // The suite already pins the merge's MAX cap -- the case where it does NOT // fire -- but nothing pinned the firing itself at height 0: every §A.1 and // §A.4 scan ends on a group of at least MIN. A writer that dropped the // merge from step 1 keeps three leaves here and emits a third S card. const gpa = std.testing.allocator; // The premise, checked rather than asserted in prose: the last entry // qualifies as a boundary, so a group of one is what the scan produces // before the merge folds it back. try std.testing.expect(zbits("f0016.c") >= 3); var es: SmEntrySet = .{}; es.init(&sm_a5_merge_files); var tb = try buildTree(gpa, es.slice(), 3, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(@as(usize, 2), tb.subs.len); try std.testing.expectEqualStrings(sm_a5_merge_1_leaf, tb.subs[0].bytes); try std.testing.expectEqualStrings(sm_a5_merge_1_name, tb.subs[0].name); try std.testing.expectEqualStrings(sm_a5_merge_2_leaf, tb.subs[1].bytes); try std.testing.expectEqualStrings(sm_a5_merge_2_name, tb.subs[1].name); // The merged leaf holds three entries -- its own two plus the folded-back // final group -- and f0016.c is not a key. var second = try parse(gpa, sm_a5_merge_2_leaf); defer second.deinit(); try std.testing.expectEqual(@as(usize, 3), second.f.len); try std.testing.expectEqualStrings("f0016.c", second.f[2].filename); try std.testing.expectEqual(@as(usize, 2), tb.s.len); try std.testing.expectEqualStrings("f0004.c", tb.s[0].filename); try std.testing.expectEqualStrings("f0006.c", tb.s[1].filename); var r: [32]u8 = undefined; var m = try smTreeManifest(gpa, "Final group merge example", "2026-07-27T00:00:00", "drh", smRCard(&sm_a5_merge_files, &r), tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expectEqualStrings(sm_a5_merge_manifest, bytes); } test "submanifests §5.1/§5.5/§A.5: the merge tree walks to its five files" { try smWalkTree( sm_a5_merge_manifest, &.{ sm_a5_merge_1_leaf, sm_a5_merge_2_leaf }, &sm_a5_merge_files, ); } test "submanifests §6.2/§A.5: the canonical-encoding tree reproduces its vectors byte-for-byte" { // §A.5 required vector: "a tree whose filenames exercise the canonical // encoding of Section 6.2 -- a name containing SP and a name containing a // backslash -- round-tripping through emission and decoding; a writer // emitting any non-canonical spelling fails byte-identity against it." // Byte-identity is the operative word: the property needs pinned artifacts, // not a generated fixture, which is why these four are vector files. const gpa = std.testing.allocator; var es: SmEntrySet = .{}; es.init(&sm_a5_enc_files); var tb = try buildTree(gpa, es.slice(), 3, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(@as(usize, 3), tb.subs.len); const leaves = [_][]const u8{ sm_a5_enc_1_leaf, sm_a5_enc_2_leaf, sm_a5_enc_3_leaf }; const leaf_names = [_][]const u8{ sm_a5_enc_1_name, sm_a5_enc_2_name, sm_a5_enc_3_name }; for (tb.subs, leaves, leaf_names) |sub, want, want_name| { try std.testing.expectEqualStrings(want, sub.bytes); try std.testing.expectEqualStrings(want_name, sub.name); } var r: [32]u8 = undefined; var m = try smTreeManifest(gpa, "Canonical encoding example", "2026-07-27T00:00:00", "drh", smRCard(&sm_a5_enc_files, &r), tb.s); defer m.deinit(); const bytes = try create(&m, gpa); defer gpa.free(bytes); try std.testing.expectEqualStrings(sm_a5_enc_manifest, bytes); // Both escapes, in both emitting positions §6.2 names: leaf F card // filenames and S card keys. try std.testing.expect(std.mem.indexOf(u8, sm_a5_enc_1_leaf, "F v00\\sw.c ") != null); try std.testing.expect(std.mem.indexOf(u8, sm_a5_enc_1_leaf, "\nF v00\\\\w.c ") != null); try std.testing.expect(std.mem.indexOf(u8, sm_a5_enc_manifest, "\nS v00\\sw.c ") != null); try std.testing.expect(std.mem.indexOf(u8, sm_a5_enc_manifest, "\nS v03\\\\w.c ") != null); } test "submanifests §6.2/§A.5: the canonical-encoding tree round-trips through decoding" { // "Decoding a canonical encoding restores the decoded name exactly", read // off the vector bytes rather than off the writer's own buffers: the walk // must hand back the nine original names, SP and backslash intact. try smWalkTree( sm_a5_enc_manifest, &.{ sm_a5_enc_1_leaf, sm_a5_enc_2_leaf, sm_a5_enc_3_leaf }, &sm_a5_enc_files, ); } test "submanifests §5.4/§A.5: the delta vector composes over the A.3 tree baseline" { // §A.5 required vector: "a delta manifest whose B card names a tree // manifest (Section 5.4), its R card verified over the composed // enumeration." §5.4: "the baseline cursor of the merge-walk ... is the // enumeration of Section 5.1, and the merge-walk itself is unchanged." // // The existing §5.4 test hand-builds the delta's F cards in memory; this // one drives the same composition off a parsed artifact, so the delta's // own bytes -- card order, the hashless delete marker, the R token -- are // part of what is pinned. const gpa = std.testing.allocator; var corpus = try SmCorpus.init(gpa, &.{ sm_a2_1_leaf, sm_a2_2_leaf }); defer corpus.deinit(); var st = corpus.store(); var base = try parse(gpa, sm_a3_manifest); defer base.deinit(); try base.flattenTree(st.seam()); var delta = try parse(gpa, sm_a5_delta_manifest); defer delta.deinit(); try std.testing.expectEqualStrings(sm_a3_name, delta.b.?.hash); try std.testing.expectEqual(@as(usize, 2), delta.f.len); try std.testing.expect(delta.f[1].hash == null); // §4.7.6 delete marker const new_main = "int main(void){return 1;}\n"; var resolved = ManifestFiles{ .baseline_hash = null, .files = base.flattened.? }; var walk = try enumerate( .{ .baseline_hash = delta.b.?.hash, .files = delta.f }, .{ .ctx = &resolved, .resolveFn = smFlatBaseline }, ); var tracked: std.ArrayList(TrackedFile) = .empty; defer tracked.deinit(gpa); while (walk.next()) |f| { if (f.hash == null) continue; const content = if (std.mem.eql(u8, f.filename, "src/main.c")) new_main else try smContentOf(&sm_a1_files, f.filename); try tracked.append(gpa, .{ .name = f.filename, .content = content }); } const want = [_][]const u8{ "README.md", "src/main.c", "src/node11.c" }; try std.testing.expectEqual(want.len, tracked.items.len); for (want, tracked.items) |w, got| try std.testing.expectEqualStrings(w, got.name); var digest: [32]u8 = undefined; try std.testing.expectEqualStrings(delta.r.?.checksum, aggregateChecksum(tracked.items, &digest)); } /// The subset of `set` whose names do not qualify as a boundary at `k`, capped /// at `count`. Filtering preserves order, so the result is still ascending, and /// the caller keeps `set` alive: every filename and hash is borrowed from it. fn smNonQualifying( gpa: std.mem.Allocator, set: *const SmNameSet, k: u4, count: usize, ) ![]FCard { var out: std.ArrayList(FCard) = .empty; errdefer out.deinit(gpa); for (set.entries) |e| { if (out.items.len == count) break; if (zbits(e.filename) >= k) continue; try out.append(gpa, e); } std.debug.assert(out.items.len == count); return out.toOwnedSlice(gpa); } test "submanifests §6.2/§A.5: a boundary-free name set stays flat to MAX and partitions past it" { // §A.5 required vectors, the pair: "a name set of at most MAX entries with // no qualifying boundary, confirming flat canonical output even with // emission enabled; and one of more than MAX such entries, confirming that // forced boundaries partition it regardless." // // Distinct from the §A.1 k = 4 case, which HAS a qualifying boundary that // MIN suppresses. Here the boundary test never fires at all, so the only // thing that can split the scan is step 1's "the open group holds MAX" // clause -- the clause a writer keying purely off zbits would omit, and // which no other vector reaches. const gpa = std.testing.allocator; const k: u4 = 3; const max_members: usize = 4 * (@as(usize, 1) << k); var set = try SmNameSet.init(gpa, 0, 400); defer set.deinit(); // Exactly MAX entries, none qualifying: one group, so step 2's // fewer-than-two exit fires and the writer emits a flat manifest. const at_max = try smNonQualifying(gpa, &set, k, max_members); defer gpa.free(at_max); for (at_max) |e| try std.testing.expect(zbits(e.filename) < k); var flat = try buildTree(gpa, at_max, k, .sha3_256); defer flat.deinit(); try std.testing.expect(flat.isFlat()); // One more entry, still none qualifying: the MAX clause forces a boundary // the name set itself never offers. The trailing group holds 1 < MIN and a // preceding group of MAX cannot absorb it, so the merge stays suppressed. const past_max = try smNonQualifying(gpa, &set, k, max_members + 1); defer gpa.free(past_max); var split = try buildTree(gpa, past_max, k, .sha3_256); defer split.deinit(); try std.testing.expect(!split.isFlat()); try std.testing.expectEqual(@as(usize, 2), split.subs.len); var first = try parse(gpa, split.subs[0].bytes); defer first.deinit(); try std.testing.expectEqual(max_members, first.f.len); var second = try parse(gpa, split.subs[1].bytes); defer second.deinit(); try std.testing.expectEqual(@as(usize, 1), second.f.len); } test "submanifests §6.4/§A.5: the Appendix A.1 list partitions identically at every k that trees it" { // §A.5's last required vector reads: "the Appendix A.1 file list // constructed under two different pinned parameters yields trees that share // no sub-manifest yet enumerate identically -- the re-tune property of // Section 6.4. (At k = 4 the list stays flat, so this vector pairs k = 3 // with a smaller value.)" // // That vector does not exist. On the A.1 list the only name that ever // qualifies is src/node11.c (zbits 8); README.md's zbits 1 is never tested, // because e1 starts the first group unconditionally. So for every k that // the qualifying name clears -- and MIN never suppresses it below k = 4 -- // the scan places one boundary in the same place, and k = 1, 2 and 3 // produce BYTE-IDENTICAL leaves. k = 4 and above go flat and produce no // sub-manifest to share. There is no pairing that satisfies the bullet. // // This test records the finding rather than the bullet: it is what an // implementer reaches when they try to author that vector, and it is the // witness attached to the erratum raised against §A.5. The re-tune property // itself (§6.4) is real -- it just needs a name set whose partition moves // with k, which the four-name A.1 list cannot be. const gpa = std.testing.allocator; var es: SmEntrySet = .{}; es.init(&sm_a1_files); inline for (.{ 1, 2, 3 }) |k| { var tb = try buildTree(gpa, es.slice(), k, .sha3_256); defer tb.deinit(); try std.testing.expect(!tb.isFlat()); try std.testing.expectEqual(@as(usize, 2), tb.subs.len); // The §A.2 leaves, unchanged: the trees share every sub-manifest. try std.testing.expectEqualStrings(sm_a2_1_name, tb.subs[0].name); try std.testing.expectEqualStrings(sm_a2_2_name, tb.subs[1].name); } inline for (.{ 4, 5, 12 }) |k| { var tb = try buildTree(gpa, es.slice(), k, .sha3_256); defer tb.deinit(); try std.testing.expect(tb.isFlat()); } } test "artifact §4.2 erratum: an empty C comment survives create -> parse (the re-mint path)" { // `import-fossil --submanifest` re-mints every manifest, so an empty // comment has to survive the WRITER as well as the reader — otherwise the // rebirth path silently retitles or drops those check-ins. The writer // emits `C` SP LF for an empty token, which is exactly the present-but- // empty form the reference implementation produces. const a = std.testing.allocator; var m0 = try parse(a, a1_2_technote); defer m0.deinit(); m0.c = .{ .comment = "" }; const out = try create(&m0, a); defer a.free(out); // The card is present on the wire, with its separating space and nothing // after it — not omitted, and not a bare `C`. try std.testing.expect(std.mem.startsWith(u8, out, "C \n") or std.mem.indexOf(u8, out, "\nC \n") != null); try std.testing.expect(std.mem.indexOf(u8, out, "\nC\n") == null); var m1 = try parse(a, out); defer m1.deinit(); try std.testing.expect(m1.c != null); try std.testing.expectEqualStrings("", m1.c.?.comment); try std.testing.expectEqual(ZVerdict.match, verifyZCard(out)); } test "rfc vectors are inert on disk: the guard block defeats the Section 4.1 gates" { // Fossil crosslinks any stored blob that parses as an artifact, so a // byte-exact vector committed as a file becomes a live check-in or // technote in the timeline of every clone, plus a phantom for each hash // its P-card names. The leading `#` block fails framing gate 3 (line-1 // byte 0 MUST be A-Z), which keeps the file inert as stored. for (rfc_vectors.guarded) |g| { errdefer std.debug.print("unguarded vector: {s}\n", .{g.name}); try std.testing.expect(std.mem.startsWith(u8, g.raw, "#")); try std.testing.expect(frame(g.raw) == null); } }