Artifact 13e3b0371b8d832030a183a5e6409d2d26d33db3ec7a0d940fd949b65b0856d2:
- File
src/artifact_test.zig
— part of check-in
[72bba34ada]
at
2026-07-28 03:31:28
on branch sub-manifest
— Sub-manifest: complete the conformance corpus
Sub-manifests have no reference implementation to check against, so the corpus is the oracle and had to be built rather than assumed. Adds the eleven vectors Appendix A.5 requires that were missing, among them the three-level tree whose absence is why the step-5 cursor contradiction survived into an implementation: no published artifact carried an interior node, so nothing exercised that path.
Every authored vector is generated by the implementation and then rechecked along a path that shares no code with it, and each derived name is compared against the constant the test pins, so no hash in the corpus is hand-written. Two fuzz targets cover S-card classification and the tree walk.
One A.5 bullet is not satisfiable as written: the re-tune vector asks for two parameters over the A.1 name list that yield trees sharing no sub-manifest, but that list's single qualifying boundary falls in the same place for the first three values and is suppressed above them. No vector was invented for it; an erratum is drafted and a test pins the finding so it stays visible. (user: dmestas, size: 300667) [annotate] [blame] [check-ins using] [more...]
const std = @import("std"); 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 = @embedFile("rfc_vectors/artifact_a1_1_sha1_manifest.txt"); const a1_1_name = "a28c83647dfa805f05f3204a7e146eb1f0d90505"; test "artifact §3.2: 40-hex name is SHA-1 over raw bytes" { try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len); var out: [64]u8 = undefined; const name = artifactName(.sha1, a1_1_manifest, &out); try std.testing.expectEqualStrings(a1_1_name, name); } test "artifact §3.2: name length selects algorithm" { try std.testing.expectEqual(HashAlgo.sha1, nameAlgo(40).?); try std.testing.expectEqual(HashAlgo.sha3_256, nameAlgo(64).?); } test "artifact §3.2: name of any other length is rejected" { const bad_lens = [_]usize{ 0, 4, 39, 41, 63, 65, 128 }; for (bad_lens) |len| { try std.testing.expect(nameAlgo(len) == null); // Fabricate an all-'a' token of that length: rejection here must be // on length alone, before any character-class check (§3.2-r1). var buf: [128]u8 = undefined; std.debug.assert(len <= buf.len); @memset(buf[0..len], 'a'); try std.testing.expect(!validateName(buf[0..len])); } } test "artifact §3.3: 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: a C card with no token MUST be rejected" { // "a C card with no token MUST be rejected" (line 1143). var buf: [256]u8 = undefined; try std.testing.expect(parseCCard(tokenizeCardLine("C\n").?, &buf) == null); // Empty token: `comment = 1*tchar` (Appendix B) needs >=1 byte. try std.testing.expect(parseCCard(tokenizeCardLine("C \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: J card rejects a missing field, an empty value token, and a third token" { 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 try std.testing.expect(parseJCard(tokenizeCardLine("J foo \n").?, &buf) == null); // empty value token try std.testing.expect(parseJCard(tokenizeCardLine("J foo bar baz\n").?, &buf) == null); // third token } 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 "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 = @embedFile("rfc_vectors/artifact_a3_rcard_manifest.txt"); 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 = @embedFile("rfc_vectors/artifact_a1_2_sha3_technote.txt"); 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 = @embedFile("rfc_vectors/submanifest_a2_1_leaf.txt"); const sm_a2_2_leaf = @embedFile("rfc_vectors/submanifest_a2_2_leaf.txt"); const sm_a3_manifest = @embedFile("rfc_vectors/submanifest_a3_tree_manifest.txt"); const sm_a4_1_leaf = @embedFile("rfc_vectors/submanifest_a4_1_leaf.txt"); const sm_a4_2_leaf = @embedFile("rfc_vectors/submanifest_a4_2_leaf.txt"); const sm_a4_3_leaf = @embedFile("rfc_vectors/submanifest_a4_3_leaf.txt"); const sm_a4_manifest = @embedFile("rfc_vectors/submanifest_a4_tree_manifest.txt"); 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 = @embedFile("rfc_vectors/submanifest_a5_three_level_interior_1.txt"); const sm_a5_3lvl_int_2 = @embedFile("rfc_vectors/submanifest_a5_three_level_interior_2.txt"); const sm_a5_3lvl_manifest = @embedFile("rfc_vectors/submanifest_a5_three_level_manifest.txt"); const sm_a5_merge_1_leaf = @embedFile("rfc_vectors/submanifest_a5_merge_leaf_1.txt"); const sm_a5_merge_2_leaf = @embedFile("rfc_vectors/submanifest_a5_merge_leaf_2.txt"); const sm_a5_merge_manifest = @embedFile("rfc_vectors/submanifest_a5_merge_manifest.txt"); const sm_a5_enc_1_leaf = @embedFile("rfc_vectors/submanifest_a5_encoding_leaf_1.txt"); const sm_a5_enc_2_leaf = @embedFile("rfc_vectors/submanifest_a5_encoding_leaf_2.txt"); const sm_a5_enc_3_leaf = @embedFile("rfc_vectors/submanifest_a5_encoding_leaf_3.txt"); const sm_a5_enc_manifest = @embedFile("rfc_vectors/submanifest_a5_encoding_manifest.txt"); const sm_a5_delta_manifest = @embedFile("rfc_vectors/submanifest_a5_delta_manifest.txt"); 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()); } }