Artifact 69a9964b0794bd6ea7e5170004d1e7e245679298e623ad29b9e1f01ce26b50c7:
- File
src/artifact.zig
— part of check-in
[e46e97cb76]
at
2026-07-09 20:02:22
on branch trunk
— Give the artifact card grammar the same seeded fuzz coverage as delta
artifact_fuzz.zig adds seven parameterized targets registered in fuzz_tests.zig: parse() on raw garbage, a structured generator that dispatches through all sixteen parseXCard entry points, tokenizeCardLine, decodeEscapes, classifyTimestamp, the frame/stripSignatureWrapper/ verifyZCard trio, and the pathname/wiki-name/reserved/compare predicates. artifact.zig gains matching fixed-seed inline blocks plus a parse() allocation-failure test, mirroring delta.zig's existing fuzz section. (user: dmestas, size: 187578) [annotate] [blame] [check-ins using]
//! Artifact Format — draft-fossil-artifact-format-00. //! //! An artifact is an immutable byte sequence named by the lowercase-hex hash //! of its content (Section 3). Structural artifacts are line-oriented "cards". //! This module owns the canonical vocabulary for the whole suite: naming, the //! card grammar, the structural types, and the two checksums (Z, R). //! //! Scope note: zit targets the version-control substrate — the check-in //! (manifest), cluster, and tag/control artifact types. The collaboration //! types (wiki, ticket, forum, attachment, technote) are carried opaquely. const std = @import("std"); /// Card-type letters. Only the version-control cards are interpreted; others /// are recognised syntactically and otherwise carried opaquely. pub const CardType = enum(u8) { B = 'B', // baseline (delta manifest) C = 'C', // comment D = 'D', // date (mtime) F = 'F', // file M = 'M', // cluster member N = 'N', // mimetype P = 'P', // parent(s) Q = 'Q', // cherrypick R = 'R', // aggregate content checksum (MD5) T = 'T', // tag U = 'U', // user Z = 'Z', // artifact self-checksum (MD5), always last }; /// Structural artifact types in scope for the version-control substrate. pub const ArtifactType = enum { manifest, // a check-in cluster, // a set of artifact hashes (sync housekeeping) control, // a tag/branch mutation other, // recognised but carried opaquely (wiki/ticket/forum/...) }; /// Hash algorithm that names an artifact (Section 3, Section 7 hash policy). /// The canonical (writer) form is always lowercase hex. pub const HashAlgo = enum { sha1, sha3_256 }; /// Compute the artifact name: the lowercase-hex hash of `content`. /// Writes into `out` and returns the populated slice (40 hex for SHA-1, /// 64 hex for SHA3-256). pub fn artifactName(algo: HashAlgo, content: []const u8, out: *[64]u8) []u8 { switch (algo) { .sha1 => { var digest: [20]u8 = undefined; std.crypto.hash.Sha1.hash(content, &digest, .{}); const hex = std.fmt.bytesToHex(digest, .lower); @memcpy(out[0..hex.len], &hex); return out[0..hex.len]; }, .sha3_256 => { var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{}); const hex = std.fmt.bytesToHex(digest, .lower); @memcpy(out[0..hex.len], &hex); return out[0..hex.len]; }, } } /// Length-to-algorithm dispatch (§3.2-m1). The two name lengths are disjoint, /// so length alone fully determines the algorithm: 40 hex digits is SHA-1, /// 64 is SHA3-256, any other length names nothing. pub fn nameAlgo(len: usize) ?HashAlgo { return switch (len) { 40 => .sha1, 64 => .sha3_256, else => null, }; } /// ABNF `lhexdig` (§3.5): a lowercase hex digit, 0-9 or a-f. Governs the /// canonical (writer/parse) form only. fn isHexLower(c: u8) bool { return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'); } /// ABNF `hexdig-ci` (§3.5): 0-9, A-F, or a-f. Used only on the prefix-lookup /// path (§3.6), which canonicalizes case; the parse path never uses this. fn isHexCi(c: u8) bool { return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F'); } /// Canonical-form validation (§3.2, §3.3, §3.5 `artifact-name`). A token is /// valid iff it is exactly 40 or 64 lowercase hex digits. Length is checked /// before character class (§3.2-r1: length gate precedes char-class check). /// Uppercase digits are rejected outright, never case-normalized (§3.3-m2). pub fn validateName(token: []const u8) bool { if (nameAlgo(token.len) == null) return false; std.debug.assert(token.len == 40 or token.len == 64); var i: usize = 0; while (i < token.len) : (i += 1) { if (!isHexLower(token[i])) return false; } return true; } /// Verify `name` against `content` (§3.4). Dispatch on length picks the /// digest to recompute; the compare is exact byte-for-byte lowercase hex. /// Wrong length, non-hex characters, and a digest mismatch collapse into the /// same `false` (§3.4-m1) — nothing here distinguishes them for the caller. /// An uppercase name never verifies either, since the compare never /// case-folds (§3.4-r1). pub fn verify(name: []const u8, content: []const u8) bool { const algo = nameAlgo(name.len) orelse return false; var buf: [64]u8 = undefined; const computed = artifactName(algo, content, &buf); std.debug.assert(computed.len == name.len); return std.mem.eql(u8, name, computed); } /// §4.7.1 hash-token position kinds. Every artifact-hash-bearing card /// position (A target/source, B, E id, F hash, G, I, M, P, Q, T target) /// maps to `.artifact_hash`; the two named exceptions get their own kind. pub const HashTokenKind = enum { artifact_hash, ticket_id, checksum }; /// §4.7.1 Hash-Token Validation Map. Validates a RAW card token against the /// length/char-class policy for `kind`. Never escape-decodes (§4.7.1-m2); /// the sole carve-out is the A card target, which the future A-card parser /// decodes first and then feeds here as `.artifact_hash` (§4.3.1). pub fn validateHashToken(kind: HashTokenKind, token: []const u8) bool { switch (kind) { // 40 or 64 case-insensitive hex (§4.7.1-m1: "MUST be exactly 40 or // 64 case-insensitive hexadecimal characters"). This is a harder // requirement than the bare artifact-name arm (§3.3), where // rejecting uppercase is only a MAY — so this does not delegate to // validateName, which takes that MAY. Length gate precedes // char-class check (§3.2-r1). .artifact_hash => { if (nameAlgo(token.len) == null) return false; std.debug.assert(token.len == 40 or token.len == 64); for (token) |c| { if (!isHexCi(c)) return false; } return true; }, // Pinned to SHA-1 length; 64 is always invalid regardless of hash // policy (§4.7.1-r1). Canonical form is lowercase (§4.7.1: "MUST // emit lowercase"); a parser MAY accept upper- or lowercase, but // acceptance is optional, so this arm takes the stricter, // lowercase-only reading (the same MAY the artifact-name arm takes). .ticket_id => { if (token.len != 40) return false; std.debug.assert(token.len == 40); for (token) |c| { if (!isHexLower(c)) return false; } return true; }, // Exactly 32 hex, parse-accept case-insensitive (§4.7.1-r2, §6.1 // md5-token = 32hexdig-ci). .checksum => { if (token.len != 32) return false; std.debug.assert(token.len == 32); for (token) |c| { if (!isHexCi(c)) return false; } return true; }, } } /// Prefix-resolution outcome (§3.6, informative — not part of the wire /// format). `one` carries the matching index into the caller's `names`. pub const Resolution = union(enum) { none, one: usize, ambiguous }; /// Resolve a hex name or prefix against a candidate universe (§3.6). /// `prefix` must be 4-64 hex characters of any case; it is lowercased before /// matching (the lookup path canonicalizes case, unlike the parse path — /// see `validateName`). One query walk covers both name lengths, so a /// cross-algorithm collision (one 40-char match, one 64-char match) reports /// `.ambiguous` exactly like a same-algorithm collision (§3.6-r1). pub fn resolvePrefix(prefix: []const u8, names: []const []const u8) error{InvalidPrefix}!Resolution { if (prefix.len < 4 or prefix.len > 64) return error.InvalidPrefix; std.debug.assert(prefix.len >= 4 and prefix.len <= 64); var lowered: [64]u8 = undefined; var i: usize = 0; while (i < prefix.len) : (i += 1) { const c = prefix[i]; if (!isHexCi(c)) return error.InvalidPrefix; lowered[i] = if (c >= 'A' and c <= 'F') c + ('a' - 'A') else c; } const query = lowered[0..prefix.len]; var first: ?usize = null; var matches: usize = 0; var n: usize = 0; while (n < names.len) : (n += 1) { std.debug.assert(matches <= names.len); if (std.mem.startsWith(u8, names[n], query)) { matches += 1; if (first == null) first = n; if (matches > 1) return .ambiguous; } } if (first) |idx| { std.debug.assert(idx < names.len); return .{ .one = idx }; } return .none; } /// The Z-card value: lowercase-hex MD5 of all stripped-content bytes that /// precede the `Z ` line (Section 6, Z card). pub fn selfChecksum(stripped_prefix: []const u8, out: *[32]u8) []u8 { var digest: [16]u8 = undefined; std.crypto.hash.Md5.hash(stripped_prefix, &digest, .{}); const hex = std.fmt.bytesToHex(digest, .lower); @memcpy(out, &hex); return out; } // --------------------------------------------------------------------------- // §4.1.1 Signature-Wrapper Removal // --------------------------------------------------------------------------- /// PGP/SSH clearsign open-armor literals (§4.1.1-r1): stripping fires only /// when one of these matches the first 34 bytes of the input, at offset 0. const pgp_open_armor = "-----BEGIN PGP SIGNED MESSAGE-----"; const ssh_open_armor = "-----BEGIN SSH SIGNED MESSAGE-----"; comptime { std.debug.assert(pgp_open_armor.len == 34); std.debug.assert(ssh_open_armor.len == 34); } /// Trailing-armor compare literals (§4.1.1-p3). The real armor line is 30 /// bytes (LF + a 29-byte dashed literal), but only 29 are ever compared: /// LF plus the first 28 armor bytes, one dash short of the closing run — /// the 30th byte, whatever it is, is never read. const pgp_close_armor = "\n-----BEGIN PGP SIGNATURE----"; const ssh_close_armor = "\n-----BEGIN SSH SIGNATURE----"; comptime { std.debug.assert(pgp_close_armor.len == 29); std.debug.assert(ssh_close_armor.len == 29); } /// Forward scan for the first blank-line separator (§4.1.1-p2): LF LF or /// LF CR LF — the sole CR tolerance anywhere in the parse pipeline. Returns /// the offset just past the blank line (start of body), or null if none is /// found before end of input. fn firstBlankLineEnd(bytes: []const u8) ?usize { var i: usize = 0; while (i < bytes.len) : (i += 1) { if (bytes[i] != '\n') continue; if (i + 1 < bytes.len and bytes[i + 1] == '\n') return i + 2; if (i + 2 < bytes.len and bytes[i + 1] == '\r' and bytes[i + 2] == '\n') return i + 3; } return null; } /// Backward scan for the trailing signature armor (§4.1.1-p3). "First /// occurrence" read right-to-left makes the rightmost match win. Returns /// the truncation length (keeping the matched LF as the final byte), or /// `body.len` when no armor is found. fn trailingArmorEnd(body: []const u8) usize { if (body.len < pgp_close_armor.len) return body.len; var p: usize = body.len - pgp_close_armor.len; while (true) : (p -= 1) { std.debug.assert(p + pgp_close_armor.len <= body.len); const slice = body[p .. p + pgp_close_armor.len]; if (std.mem.eql(u8, slice, pgp_close_armor) or std.mem.eql(u8, slice, ssh_close_armor)) { return p + 1; // truncate here, keeping the matched LF } if (p == 0) break; } return body.len; } /// §4.1.1 signature-wrapper removal. Total: never fails. Returns `bytes` /// unchanged unless the first 34 bytes are a recognized open armor AND a /// blank line follows somewhere in the input — either condition failing /// leaves the input wholly unstripped (§4.1.1-r1, -m1). pub fn stripSignatureWrapper(bytes: []const u8) []const u8 { const is_pgp = bytes.len >= pgp_open_armor.len and std.mem.eql(u8, bytes[0..pgp_open_armor.len], pgp_open_armor); const is_ssh = bytes.len >= ssh_open_armor.len and std.mem.eql(u8, bytes[0..ssh_open_armor.len], ssh_open_armor); if (!is_pgp and !is_ssh) return bytes; const body_start = firstBlankLineEnd(bytes) orelse return bytes; std.debug.assert(body_start <= bytes.len); const body = bytes[body_start..]; const body_end = trailingArmorEnd(body); std.debug.assert(body_end <= body.len); const result = body[0..body_end]; std.debug.assert(result.len <= bytes.len); return result; } // --------------------------------------------------------------------------- // §4.1 Artifact Framing // --------------------------------------------------------------------------- /// §6.2 Z-card verification outcome (Table 6). pub const ZVerdict = enum { absent, match, mismatch }; /// §6.2 Z self-checksum, evaluated positionally on the final 35 bytes of /// `content` (already wrapper-stripped): the marker sits at n-35, the /// digest at n-33..n-1. Never reads `content[n-1]`, the artifact's /// terminating byte — that byte belongs to the §4.1 raw-LF framing rule. pub fn verifyZCard(content: []const u8) ZVerdict { const n = content.len; if (n < 35) return .absent; if (content[n - 35] != 'Z' or content[n - 34] != ' ') return .absent; const stored = content[n - 33 .. n - 1]; std.debug.assert(stored.len == 32); var digest_buf: [32]u8 = undefined; const computed = selfChecksum(content[0 .. n - 35], &digest_buf); std.debug.assert(computed.len == 32); return if (std.mem.eql(u8, stored, computed)) .match else .mismatch; } /// §4.1 framing gates, evaluated in RFC order before any card is /// interpreted. Returns the wrapper-stripped, gate-passed content, or null /// if the input fails any gate — every rejection reason collapses to null, /// as the RFC only ever says "reject" (the §4.1.2 fast well-formedness /// probe reads this way too). /// /// The final §4.1 rule — unconsumed bytes after the card loop MUST cause /// rejection — is not enforced here: it is defined relative to the card /// loop, which this module does not yet implement. It lands with the §4.2 /// card-loop parser, which calls `frame` as its entry seam. pub fn frame(bytes: []const u8) ?[]const u8 { // Gate 1: raw bytes, non-empty, final byte LF — checked before strip. if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; // Gate 2: strip signature wrapper, if present. const stripped = stripSignatureWrapper(bytes); if (stripped.len > 0) std.debug.assert(stripped[stripped.len - 1] == '\n'); // Gate 3: strict line-1 (length, byte0 A-Z, byte1 SP). if (stripped.len < 10) return null; if (stripped[0] < 'A' or stripped[0] > 'Z') return null; if (stripped[1] != ' ') return null; // Gate 4: Z self-checksum — a mismatch rejects; absent or matching pass. if (verifyZCard(stripped) == .mismatch) return null; return stripped; } // --------------------------------------------------------------------------- // §4.2 Line and Token Grammar / §4.2.2 Closed Card Alphabet // --------------------------------------------------------------------------- /// §4.2.2 closed alphabet: true for exactly the 21 defined card letters. /// A-Z minus the five in-range gaps (O S V X Y) — closed, no /// forward-compatibility path for an unknown letter (RFC lines 623-627). pub fn isCardLetter(b: u8) bool { return switch (b) { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'T', 'U', 'W', 'Z' => true, else => false, }; } comptime { var count: usize = 0; var b: usize = 0; while (b < 256) : (b += 1) { if (isCardLetter(@intCast(b))) count += 1; } std.debug.assert(count == 21); std.debug.assert(!isCardLetter('O')); std.debug.assert(!isCardLetter('S')); std.debug.assert(!isCardLetter('V')); std.debug.assert(!isCardLetter('X')); std.debug.assert(!isCardLetter('Y')); std.debug.assert(!isTokenByte(' ')); std.debug.assert(!isTokenByte('\n')); std.debug.assert(isTokenByte(0x00)); std.debug.assert(isTokenByte(0xFF)); } /// §4.2 tchar: any octet except SP (0x20) and LF (0x0A). pub fn isTokenByte(b: u8) bool { return b != ' ' and b != '\n'; } /// Splits a card's raw argument bytes on SP only (§4.2-m2 — tab is never a /// delimiter). `rest == null` yields zero tokens (argument-less card); /// `rest.?.len == 0` yields exactly one empty token (§4.7.17's "empty /// token" case) before going null. pub const TokenIterator = struct { rest: ?[]const u8, pub fn next(self: *TokenIterator) ?[]const u8 { const s = self.rest orelse return null; var i: usize = 0; while (i < s.len) : (i += 1) { if (s[i] == ' ') { self.rest = s[i + 1 ..]; return s[0..i]; } } self.rest = null; return s; } }; /// One tokenized card (§4.2 `card`). Borrows from the input; no allocation. pub const CardLine = struct { letter: u8, /// null => argument-less card (`X\n`), 0 tokens. /// slice => SP form (`X ...\n`); raw arg bytes with the LF and the one /// leading SP removed. May be "" (one empty token). args: ?[]const u8, pub fn tokens(self: CardLine) TokenIterator { return .{ .rest = self.args }; } }; /// Tokenize one card, given its full byte extent including the terminating /// LF (the card loop supplies the extent; W's byte-counted body is that /// loop's concern, not this function's — §4.7.18). Returns null iff the /// letter is outside the closed 21-letter alphabet (§4.2.2-r1,-r2) or the /// byte after the letter is neither SP nor LF (§4.2-m1). pub fn tokenizeCardLine(line: []const u8) ?CardLine { std.debug.assert(line.len >= 1); std.debug.assert(line[line.len - 1] == '\n'); // Non-W invariant: no embedded LF before the terminator. The W card's // byte-counted body is the sole exception (§4.7.18), realized by the // card loop, not here. var i: usize = 0; while (i < line.len - 1) : (i += 1) { std.debug.assert(line[i] != '\n'); } if (line.len < 2) return null; if (!isCardLetter(line[0])) return null; const result: CardLine = if (line[1] == '\n') .{ .letter = line[0], .args = null } else if (line[1] == ' ') .{ .letter = line[0], .args = line[2 .. line.len - 1] } else return null; std.debug.assert(isCardLetter(result.letter)); return result; } // --------------------------------------------------------------------------- // §4.4 Card Ordering // --------------------------------------------------------------------------- comptime { // Z is the last defined letter (§4.4 "Nothing follows Z", lines 739-741): // every card letter sorts at or below it. var b: usize = 0; while (b < 256) : (b += 1) { if (isCardLetter(@intCast(b))) std.debug.assert(b <= 'Z'); } // Premises the N-after-P exception rests on (lines 714-717, §A.1 2499-2500). std.debug.assert('N' < 'P'); std.debug.assert('E' < 'P'); } /// §4.4 card-type ordering, fed one accepted card letter at a time in /// document order. Holds the previous letter and whether a valid E card has /// been presented (the N-after-P technote exception, lines 714-717). pub const OrderState = struct { prev: ?u8 = null, e_seen: bool = false, /// Admit `letter` as the next card. Returns false iff it is out of /// order and not the N-after-P exception; the caller collapses false /// to reject, matching the module's "every rejection reason collapses /// to null/false" posture (§4.1). Pure predicate: no error set. pub fn admit(self: *OrderState, letter: u8) bool { std.debug.assert(isCardLetter(letter)); if (self.prev) |p| std.debug.assert(isCardLetter(p)); const ok = if (self.prev == null) true else if (letter >= self.prev.?) true else letter == 'N' and self.prev.? == 'P' and self.e_seen; if (!ok) return false; if (letter == 'E') self.e_seen = true; self.prev = letter; std.debug.assert(self.prev.? == letter); std.debug.assert(!(letter == 'E') or self.e_seen); return true; } }; // --------------------------------------------------------------------------- // §4.3 Character-Escape Encoding // --------------------------------------------------------------------------- /// §4.3 Table 1: the decoded octet for a recognized escape successor, or /// null for an unrecognized one. Exactly eight pairs are defined; every /// other successor byte falls through to `else`. fn escapeOctet(c: u8) ?u8 { return switch (c) { 'n' => 0x0A, // LF 's' => 0x20, // SP 't' => 0x09, // TAB 'r' => 0x0D, // CR 'v' => 0x0B, // VT 'f' => 0x0C, // FF '0' => 0x00, // NUL '\\' => 0x5C, // backslash else => null, }; } comptime { var count: usize = 0; var b: usize = 0; while (b < 256) : (b += 1) { if (escapeOctet(@intCast(b)) != null) count += 1; } std.debug.assert(count == 8); std.debug.assert(escapeOctet('n').? == 0x0A); std.debug.assert(escapeOctet('s').? == 0x20); std.debug.assert(escapeOctet('0').? == 0x00); std.debug.assert(escapeOctet('\\').? == 0x5C); } /// §4.3 in-place escape decode of one free-text token. Writes the decoded /// bytes into `out` and returns the used prefix. Never errors (§4.3: "No /// error is raised"). Precondition: `out.len >= token.len` — decoded length /// never exceeds input length (a recognized or unrecognized escape turns 2 /// input bytes into 1; an ordinary byte or a lone trailing backslash is 1:1, /// §4.3-amb1). pub fn decodeEscapes(token: []const u8, out: []u8) []u8 { std.debug.assert(out.len >= token.len); var i: usize = 0; var w: usize = 0; while (i < token.len) { std.debug.assert(w <= i); if (token[i] == '\\' and i + 1 < token.len) { out[w] = escapeOctet(token[i + 1]) orelse token[i + 1]; i += 2; } else { // Ordinary byte, or a backslash with no successor (§4.3-amb1): // pass through literally. out[w] = token[i]; i += 1; } w += 1; } std.debug.assert(w <= token.len); return out[0..w]; } // --------------------------------------------------------------------------- // §4.5 Occurrence Classes and Intra-Type Ordering // --------------------------------------------------------------------------- /// §4.5.1 occurrence class of a card letter (lines 753-766). `single`: a /// duplicate MUST be rejected. `repeatable`: each occurrence appends to the /// artifact's growing list for that type — a duplicate is never a syntax /// error. `neither`: the Z card only — no duplicate guard, no list. pub const OccurrenceClass = enum { single, repeatable, neither }; /// §4.5.1 classifier (lines 755-764). Precondition: `isCardLetter(letter)`. pub fn occurrenceClass(letter: u8) OccurrenceClass { std.debug.assert(isCardLetter(letter)); return switch (letter) { 'A', 'B', 'C', 'D', 'E', 'G', 'H', 'I', 'K', 'L', 'N', 'R', 'U', 'W' => .single, 'F', 'J', 'M', 'T', 'P', 'Q' => .repeatable, 'Z' => .neither, else => unreachable, // isCardLetter admits only the 21 letters above }; } comptime { var single: usize = 0; var repeatable: usize = 0; var neither: usize = 0; var b: usize = 0; while (b < 256) : (b += 1) { if (!isCardLetter(@intCast(b))) continue; switch (occurrenceClass(@intCast(b))) { .single => single += 1, .repeatable => repeatable += 1, .neither => neither += 1, } } std.debug.assert(single == 14); std.debug.assert(repeatable == 6); std.debug.assert(neither == 1); std.debug.assert(occurrenceClass('Z') == .neither); } /// §4.5.2 Table 2 intra-run check structure for a repeatable card's list /// (lines 789-809). `ascending`: strict ascent by a single decoded key (F, /// J, M — the key itself varies by letter, extracted by the caller). /// `tag_two_level`: T's (tag name, then target hash) rule. `unordered`: P /// (semantic order, lines 804-805) and Q (no check at all, lines 807-808). pub const IntraRunRule = enum { ascending, tag_two_level, unordered }; /// §4.5.2 Table 2 dispatch. Non-null iff `letter` is repeatable; null for /// every single-occurrence letter and for Z. Precondition: `isCardLetter`. pub fn intraRunRule(letter: u8) ?IntraRunRule { std.debug.assert(isCardLetter(letter)); return switch (letter) { 'F', 'J', 'M' => .ascending, 'T' => .tag_two_level, 'P', 'Q' => .unordered, else => null, }; } comptime { var b: usize = 0; while (b < 256) : (b += 1) { if (!isCardLetter(@intCast(b))) continue; const letter: u8 = @intCast(b); const has_rule = intraRunRule(letter) != null; const is_repeatable = occurrenceClass(letter) == .repeatable; std.debug.assert(has_rule == is_repeatable); } std.debug.assert(intraRunRule('F').? == .ascending); std.debug.assert(intraRunRule('J').? == .ascending); std.debug.assert(intraRunRule('M').? == .ascending); std.debug.assert(intraRunRule('T').? == .tag_two_level); std.debug.assert(intraRunRule('P').? == .unordered); std.debug.assert(intraRunRule('Q').? == .unordered); std.debug.assert(intraRunRule('Z') == null); } /// §4.5.1-r1 duplicate guard, fed one accepted card letter at a time. /// Fixed 26-slot membership set indexed by `letter - 'A'` — a card letter is /// always in A..Z (`isCardLetter`), so 26 slots cover every case with no /// dynamic sizing. pub const SingleOccurrenceSet = struct { seen: [26]bool = .{false} ** 26, /// Admit `letter` as an occurrence. Returns false iff `letter` is /// single-occurrence and this is its second-or-later appearance; /// repeatable letters and Z are always admitted (§4.5.1-m1, §4.5.2-m3, /// the Z paragraph at lines 764-766). pub fn admit(self: *SingleOccurrenceSet, letter: u8) bool { std.debug.assert(isCardLetter(letter)); if (occurrenceClass(letter) != .single) return true; const idx = letter - 'A'; std.debug.assert(idx < 26); if (self.seen[idx]) return false; self.seen[idx] = true; std.debug.assert(self.seen[idx]); return true; } }; /// §4.5.3 the canonical comparator (lines 823-836): raw-byte comparison, /// octets as unsigned values (u8 is already unsigned), locale-independent, /// no Unicode normalization, no case folding. On a common-prefix tie the /// shorter string sorts first (§4.5.3-m1). No byte value — including `/` /// 0x2F — gets special treatment (§4.5.3-m2): there is no branch for it. pub fn compareBytes(a: []const u8, b: []const u8) std.math.Order { const n = @min(a.len, b.len); var i: usize = 0; while (i < n) : (i += 1) { // n = min(a.len, b.len), so this is what actually justifies the // unchecked a[i] / b[i] below — not a restatement of `i < n`. std.debug.assert(i < a.len and i < b.len); if (a[i] != b[i]) return std.math.order(a[i], b[i]); } return std.math.order(a.len, b.len); } comptime { std.debug.assert(compareBytes("A", "a") == .lt); // no case folding std.debug.assert(compareBytes("abc", "abcd") == .lt); // shorter-first std.debug.assert(compareBytes("a.b", "a/b") == .lt); // 0x2E < 0x2F, '/' unprivileged } /// §4.5.3-m1 "an absent value sorts before any present value." Both absent /// compares equal; one absent sorts before any present value including "". /// Two present values compare by `compareBytes`. pub fn compareOptional(a: ?[]const u8, b: ?[]const u8) std.math.Order { if (a == null and b == null) return .eq; if (a == null) return .lt; if (b == null) return .gt; return compareBytes(a.?, b.?); } /// §4.5.2-r4 T two-level rule: compare tag name first; on a name tie, /// target hash must strictly increase, so the hash comparison decides. pub fn compareTag(a_name: []const u8, a_hash: []const u8, b_name: []const u8, b_hash: []const u8) std.math.Order { const name_order = compareBytes(a_name, b_name); if (name_order != .eq) return name_order; return compareBytes(a_hash, b_hash); } /// §4.6.1 Simple-Pathname predicate. Operates on the DECODED form of a token /// (§4.6 line 853), as a sequence of unsigned octets. Applies to the A-card /// filename and to the F-card filename and old-name (§4.6.1-p1, line 858). /// Returns true iff the name is ACCEPTED; false iff it must be rejected. /// Rejection is decidable from the '/'-separator structure alone (§4.6.1-m1): /// no byte-content restriction — backslashes, spaces, control bytes, and /// arbitrary/invalid UTF-8 all pass. The stricter writer profile does NOT /// constrain acceptance (§4.6.1-m2, line 888). pub fn isSimplePathname(name: []const u8) bool { if (name.len == 0) return false; // §4.6.1-r1: empty var start: usize = 0; var i: usize = 0; while (i < name.len) : (i += 1) { // bound: i++ each step, ends at name.len std.debug.assert(start <= i); // invariant: start never runs past i if (name[i] != '/') continue; if (!pathElementOk(name[start..i])) return false; start = i + 1; // element after the '/' } std.debug.assert(start <= name.len); return pathElementOk(name[start..name.len]); // final element, no trailing '/' } /// A single '/'-delimited element is bad iff empty (r2/r5/r6) or exactly /// `.`/`..` (r3/r4/r7). See `isSimplePathname` doc comment for the mapping /// from these two checks back to the seven enumerated RFC bullets. fn pathElementOk(elem: []const u8) bool { if (elem.len == 0) return false; if (std.mem.eql(u8, elem, ".") or std.mem.eql(u8, elem, "..")) return false; return true; } comptime { std.debug.assert(isSimplePathname("alpha.txt")); // §A.3 accept std.debug.assert(isSimplePathname("a/b/c")); // nested accept std.debug.assert(isSimplePathname("...")); // r3/r7 are exact: "..." passes std.debug.assert(!isSimplePathname("")); // r1 std.debug.assert(!isSimplePathname("/a")); // r2 std.debug.assert(!isSimplePathname(".")); // r3 std.debug.assert(!isSimplePathname("./a")); // r4 std.debug.assert(!isSimplePathname("a//b")); // r5 std.debug.assert(!isSimplePathname("a/")); // r6 std.debug.assert(!isSimplePathname("a/../b")); // r7 } const WIKI_NAME_MAX: usize = 100; // §4.6.2-r5 upper bound, in bytes /// §4.6.2 Wiki-Name predicate. Operates on the DECODED form of a token as a /// sequence of unsigned octets. Applies to the L-card title and to the A-card /// target in its wiki-name alternative (§4.6.2-p1, line 903). Returns true iff /// ACCEPTED; false iff it must be rejected. Reject iff any of: first byte /// <= 0x20 (r1: empty/leading-space/leading-control); a subsequent byte < 0x20 /// (r2: control incl. tab/LF); consecutive 0x20 0x20 anywhere (r3); final byte /// 0x20 (r4); byte length outside 1..100 (r5). Bytes >= 0x21 are otherwise /// unconstrained: 0x7F and all bytes >= 0x80 accepted, no UTF-8 check, length /// counted in bytes not characters (§4.6.2-m1, lines 919-921). pub fn isWikiName(name: []const u8) bool { if (name.len < 1 or name.len > WIKI_NAME_MAX) return false; // r5 (also empty) if (name[0] <= 0x20) return false; // r1: first byte if (name[name.len - 1] == 0x20) return false; // r4: final byte var i: usize = 1; while (i < name.len) : (i += 1) { // bound: i++ each step, ends at name.len // i starts at 1 and only grows, so this is what actually justifies // the unchecked i - 1 below — not a restatement of i < name.len. std.debug.assert(i >= 1); const b = name[i]; if (b < 0x20) return false; // r2: subsequent control byte if (b == 0x20 and name[i - 1] == 0x20) return false; // r3: consecutive spaces } return true; } comptime { std.debug.assert(isWikiName("A")); // 1-byte accept (r5 lower) std.debug.assert(isWikiName("Wiki Page")); // single interior space ok std.debug.assert(isWikiName("x" ** 100)); // r5 upper accept std.debug.assert(isWikiName("a\x7f")); // 0x7F accepted (m1) std.debug.assert(isWikiName("a\x80")); // >=0x80 accepted (m1) std.debug.assert(!isWikiName("")); // r5/r1: empty std.debug.assert(!isWikiName(" x")); // r1: leading space std.debug.assert(!isWikiName("\x1fx")); // r1: leading control std.debug.assert(!isWikiName("a\tb")); // r2: tab std.debug.assert(!isWikiName("a\nb")); // r2: LF std.debug.assert(!isWikiName("a b")); // r3: double space std.debug.assert(!isWikiName("a ")); // r4: trailing space std.debug.assert(!isWikiName("x" ** 101)); // r5 upper reject } /// Sidecar suffixes stripped in reserved-filename step 1 (§4.6.3-p1, line /// 926-929). Order does not matter for correctness: the three are mutually /// non-suffixing (none is a suffix of another), so at most one can ever /// match a given trailing region; kept as a fixed comptime-unrolled list. const RESERVED_SIDECAR_SUFFIXES = [_][]const u8{ "-journal", "-wal", "-shm" }; /// §4.6.3 Reserved-filename predicate. Operates on the decoded CURRENT /// F-card filename only -- the old-name field is never tested (line 957). /// Returns true iff the three-step procedure (lines 926-937) matches, which /// means the caller MUST silently drop the F card, not reject it (r2, line /// 961): reserved-ness is a bool drop signal, never a ParseError variant. pub fn isReservedFilename(name: []const u8) bool { // Step 1 (p1, lines 926-929): strip exactly one sidecar suffix. The // `!sidecar_stripped` guard is what makes this "exactly one" -- once a // suffix matches we stop, we never re-scan the shortened remainder. var remaining = name; var sidecar_stripped = false; inline for (RESERVED_SIDECAR_SUFFIXES) |suffix| { if (!sidecar_stripped and std.ascii.endsWithIgnoreCase(remaining, suffix)) { remaining = remaining[0 .. remaining.len - suffix.len]; sidecar_stripped = true; } } // Step 2 (p2, lines 933-934): reserved tail, case-insensitive. const tail_fossil = "_FOSSIL_"; const tail_fslckout = ".fslckout"; const tail_len: usize = if (std.ascii.endsWithIgnoreCase(remaining, tail_fossil)) tail_fossil.len else if (std.ascii.endsWithIgnoreCase(remaining, tail_fslckout)) tail_fslckout.len else return false; std.debug.assert(remaining.len >= tail_len); // endsWithIgnoreCase guarantees this // Step 3 (p3, lines 936-937): whole-string, preceding '/', or sidecar. if (remaining.len == tail_len) return true; // 3a: tail is the whole string if (sidecar_stripped) return true; // 3c: a sidecar suffix was stripped std.debug.assert(remaining.len > tail_len); // 3a ruled out above; index is in-bounds return remaining[remaining.len - tail_len - 1] == '/'; // 3b } comptime { std.debug.assert(isReservedFilename("_FOSSIL_")); // 3a whole string std.debug.assert(isReservedFilename(".fslckout")); // 3a whole string std.debug.assert(isReservedFilename("dir/_FOSSIL_")); // 3b preceding '/' std.debug.assert(isReservedFilename("x_FOSSIL_-wal")); // 3c sidecar stripped std.debug.assert(isReservedFilename("_FOSSIL_-WAL")); // p1 case-insensitive strip std.debug.assert(isReservedFilename("_fossil_")); // p2 case-insensitive tail std.debug.assert(!isReservedFilename("x_FOSSIL_")); // r1: no step-3 condition holds std.debug.assert(!isReservedFilename("CON")); // m1: no reserved tail at all std.debug.assert(!isReservedFilename("readme.txt")); // p2 fails: no tail std.debug.assert(!isReservedFilename("")); // underflow guard boundary std.debug.assert(!isReservedFilename("foo-wal")); // strip leaves no tail } /// §4.6.3-m2 sequencing (lines 967-973): "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, and the next F card is compared against that same retained /// entry. The dropped name never becomes the comparison anchor. A /// validator that checks order before dropping reproduces this behavior /// exactly." `OutOfOrder` is the §4.5.2-r1 sort rejection (equal or /// descending); it is distinct from a drop, which is not an error. pub const FCardSortError = error{OutOfOrder}; /// Feeds F cards one at a time, in wire order, and tracks the effective /// file set (§4.6.3-r2): a reserved-named card is silently dropped, not /// admitted, so it never appears in the retained set and never replaces /// `anchor`. `admit` runs the §4.5.2 sort check first and the §4.6.3 /// reserved check second, per the sequencing clause above. pub const FCardAdmission = struct { /// Decoded current filename of the last RETAINED F card, or null /// before the first admitted card. This is the sort-check anchor; a /// dropped card's name is never written here. anchor: ?[]const u8 = null, /// Admit one F card by its decoded CURRENT filename only (§4.6.3 lines /// 957-959: the old-name field is never tested by this rule -- there /// is no old-name parameter for it to reach). Order is checked against /// `anchor` first: equal or descending is `OutOfOrder`, matching /// §4.5.2-r1, and applies even to a reserved name -- reserved-ness /// does not exempt a card from the sort check. Only a card that passes /// the sort check is then tested for reserved-ness. Returns true and /// advances `anchor` to `name` iff retained; returns false (anchor /// unchanged) iff dropped. pub fn admit(self: *FCardAdmission, name: []const u8) FCardSortError!bool { if (self.anchor) |prev| { if (compareBytes(prev, name) != .lt) return FCardSortError.OutOfOrder; } if (isReservedFilename(name)) return false; // dropped: anchor untouched self.anchor = name; std.debug.assert(self.anchor != null and std.mem.eql(u8, self.anchor.?, name)); return true; } }; // --------------------------------------------------------------------------- // §4.6.4 Timestamps // --------------------------------------------------------------------------- /// The five accepted §4.6.4 D/E timestamp token forms (p1: the token is /// classified RAW, never escape-decoded). `date_time` is the broad reader /// form -- leading '-', whitespace/'T' separator, and tz all permitted; a /// writer that must satisfy m8 uses `isWriterTimestamp` for the narrower /// canonical subset. pub const TimestampForm = enum { date_time, date, time, relative, julian }; /// §4.6.4 classify the raw D/E card timestamp token. Returns the matched /// form, or null iff the token is rejected outright. Pure: reads no clock, /// filesystem, or RNG state (m9 -- `now`/`subsec`/`subsecond` are classified /// as `.relative`, never resolved to an instant here; resolution happens at /// [FossilRepoState] ingestion time, outside this module). pub fn classifyTimestamp(token: []const u8) ?TimestampForm { if (token.len == 0) return null; // negative space: nothing to classify if (matchDate(token)) |dm| { if (dm.cur == token.len) { if (!dateGateOk(dm.y_neg, dm.y, dm.m, dm.d)) return null; // r3 return .date; } // Not a bare date: the remainder must be a date-time separator + // time [+ tz], or the whole token is rejected -- a matched date // prefix is never reinterpreted as a bare time (p1/r1 tokens rely // on this: they fail here, not by falling through below). var cur = dm.cur; const sep_start = cur; while (cur < token.len and (token[cur] == 'T' or std.ascii.isWhitespace(token[cur]))) : (cur += 1) {} if (cur == sep_start) return null; // m1 "1 or more"; r1 lowercase t never advances this const tm = matchTime(token, cur) orelse return null; const tz_end = matchTz(token, tm.cur); if (tz_end != token.len) return null; // trailing garbage after tz if (!dateGateOk(dm.y_neg, dm.y, dm.m, dm.d)) return null; // r3 return .date_time; } if (matchTime(token, 0)) |tm| { if (tm.cur == token.len) return .time; // m4: implied 2000-01-01, gate always passes } if (isRelativeLiteral(token)) return .relative; // m5 if (matchBareDecimal(token)) return .julian; // m6/r3 return null; } /// §4.6.4-m8 writer grammar: `date %x54 time [frac]` -- YYYY-MM-DD, exactly /// the byte 'T' (never lowercase, never whitespace, never a bare space), then /// HH:MM:SS with SS mandatory, then an optional `.frac`. No leading sign, no /// timezone. True iff `token` is exactly that canonical interoperable form /// (w1: `2017-12-06T15:36:24.287`). This is the oracle a future D/E emitter /// and any round-trip check call; parallels the hash-token canonical-writer /// split already in this module (see `validateName` vs. its callers). pub fn isWriterTimestamp(token: []const u8) bool { if (token.len == 0) return false; // negative space const dm = matchDate(token) orelse return false; if (dm.y_neg) return false; // writer form: no leading '-' if (dm.cur >= token.len or token[dm.cur] != 'T') return false; // exactly one 'T', no ws const tm = matchTime(token, dm.cur + 1) orelse return false; if (!tm.had_seconds) return false; // writer time = HH:MM:SS, SS mandatory if (tm.cur != token.len) return false; // no tz permitted return dateGateOk(dm.y_neg, dm.y, dm.m, dm.d); // r3 } /// Parse exactly `n` ASCII digit bytes from the front of `s` as an unsigned /// int, or null if fewer than `n` bytes remain or any of the first `n` bytes /// is not '0'-'9' (§4.6.4-m7 "exactly N digits" -- no sign, no short/long /// reads). Wire-derived input: failure is a guard return, never a panic. fn parseFixedDigits(s: []const u8, comptime n: usize) ?u32 { comptime std.debug.assert(n <= 4); // largest field here is the 4-digit year if (s.len < n) return null; var v: u32 = 0; for (s[0..n]) |b| { // bound: fixed at comptime n if (b < '0' or b > '9') return null; v = v * 10 + (b - '0'); } std.debug.assert(v < comptime std.math.pow(u32, 10, n)); // n digits bound the value return v; } const DateMatch = struct { cur: usize, y_neg: bool, y: u32, m: u32, d: u32 }; /// §4.6.4-m3/m7 date: optional leading '-', then YYYY-MM-DD. Month is /// range-checked to 01-12 and day to 01-31 here (m7); there is no /// calendar-aware check, so 2024-02-31 parses fine (that acceptance is /// exercised by the caller, not rejected here). Returns the match plus the /// cursor just past the day field, or null on any mismatch. fn matchDate(token: []const u8) ?DateMatch { var cur: usize = 0; var y_neg = false; if (token.len > 0 and token[0] == '-') { y_neg = true; cur = 1; } const y = parseFixedDigits(token[cur..], 4) orelse return null; cur += 4; if (cur >= token.len or token[cur] != '-') return null; cur += 1; const m = parseFixedDigits(token[cur..], 2) orelse return null; if (m < 1 or m > 12) return null; // m7: month 01-12 std.debug.assert(m >= 1 and m <= 12); cur += 2; if (cur >= token.len or token[cur] != '-') return null; cur += 1; const d = parseFixedDigits(token[cur..], 2) orelse return null; if (d < 1 or d > 31) return null; // m7: day 01-31, no calendar check std.debug.assert(d >= 1 and d <= 31); cur += 2; std.debug.assert(cur <= token.len); return DateMatch{ .cur = cur, .y_neg = y_neg, .y = y, .m = m, .d = d }; } /// §4.6.4-r3 JD>0 gate, date-granularity reading (RESOLVED operator decision, /// 2026-07-09 -- see ambiguities.md): reject a date/date-time token iff its /// signed (year, month, day) is lexicographically <= (-4713, 11, 24), /// independent of time-of-day or tz. `year` is the signed astronomical year /// (y_neg negates `y`); this is what makes -4713-11-24T18:00:00 reject /// despite its positive fractional Julian Date. fn dateGateOk(y_neg: bool, y: u32, m: u32, d: u32) bool { std.debug.assert(m >= 1 and m <= 12); std.debug.assert(d >= 1 and d <= 31); const year: i32 = if (y_neg) -@as(i32, @intCast(y)) else @as(i32, @intCast(y)); if (year < -4713) return false; if (year > -4713) return true; if (m < 11) return false; if (m > 11) return true; return d > 24; } const TimeMatch = struct { cur: usize, had_seconds: bool }; /// §4.6.4-m1/m4/m7 time-of-day: HH:MM, HH:MM:SS, or HH:MM:SS.frac. HH is /// 00-24, MM/SS are 00-59, frac (only legal after ':SS') is 1+ digits. /// Returns the cursor just past the match plus whether ':SS' was present, or /// null on any mismatch. fn matchTime(token: []const u8, i: usize) ?TimeMatch { var cur = i; const hh = parseFixedDigits(token[cur..], 2) orelse return null; if (hh > 24) return null; // m7: hour 00-24 std.debug.assert(hh <= 24); cur += 2; if (cur >= token.len or token[cur] != ':') return null; cur += 1; const mm = parseFixedDigits(token[cur..], 2) orelse return null; if (mm > 59) return null; // m7: minute 00-59 std.debug.assert(mm <= 59); cur += 2; var had_seconds = false; if (cur < token.len and token[cur] == ':') { cur += 1; const ss = parseFixedDigits(token[cur..], 2) orelse return null; if (ss > 59) return null; // m7: second 00-59 std.debug.assert(ss <= 59); cur += 2; had_seconds = true; if (cur < token.len and token[cur] == '.') { cur += 1; const frac_start = cur; while (cur < token.len and std.ascii.isDigit(token[cur])) : (cur += 1) {} // bound: token.len if (cur == frac_start) return null; // m7: frac needs 1+ digits std.debug.assert(cur > frac_start); } } std.debug.assert(cur <= token.len); return TimeMatch{ .cur = cur, .had_seconds = had_seconds }; } /// §4.6.4-m2 optional timezone: optional whitespace, then '+HH:MM', /// '-HH:MM', 'Z', or 'z'. HH/MM are exactly 2 digits each, structural only -- /// the RFC gives no tz range and no conforming writer ever emits one (m8). /// Returns the cursor past a matched tz, or `i` unchanged if none is present: /// that "no advance" is what makes the whole timezone optional -- the /// caller's final `cur == token.len` check does the actual accept/reject. fn matchTz(token: []const u8, i: usize) usize { var cur = i; while (cur < token.len and std.ascii.isWhitespace(token[cur])) : (cur += 1) {} // bound: token.len if (cur >= token.len) return i; // no tz; don't consume trailing ws const c = token[cur]; if (c == 'Z' or c == 'z') return cur + 1; if (c == '+' or c == '-') { var p = cur + 1; _ = parseFixedDigits(token[p..], 2) orelse return i; p += 2; if (p >= token.len or token[p] != ':') return i; p += 1; _ = parseFixedDigits(token[p..], 2) orelse return i; p += 2; std.debug.assert(p <= token.len); return p; } return i; // no tz } /// §4.6.4-m5: `now`, `subsec`, or `subsecond`, case-insensitive, whole-token /// exact match only. fn isRelativeLiteral(token: []const u8) bool { return std.ascii.eqlIgnoreCase(token, "now") or std.ascii.eqlIgnoreCase(token, "subsec") or std.ascii.eqlIgnoreCase(token, "subsecond"); } /// §4.6.4-m6/r3 bare decimal: lexically `1*DIGIT ["." 1*DIGIT]` (>=1 integer /// digit; a dot requires >=1 fraction digit, mirroring the time frac rule), /// consuming the whole token, then accepted iff its value r satisfies /// `0 < r < 5373484.5` (m6 gives `0 <= r`; r3 tightens to `r > 0`, dropping /// the numeric token "0"). fn matchBareDecimal(token: []const u8) bool { var i: usize = 0; while (i < token.len and std.ascii.isDigit(token[i])) : (i += 1) {} // bound: token.len if (i == 0) return false; // need >=1 integer digit std.debug.assert(i >= 1); if (i < token.len and token[i] == '.') { var j = i + 1; while (j < token.len and std.ascii.isDigit(token[j])) : (j += 1) {} // bound: token.len if (j == i + 1) return false; // dot requires >=1 fraction digit i = j; } if (i != token.len) return false; // trailing byte the lexical grammar doesn't cover const r = std.fmt.parseFloat(f64, token) catch return false; // unreachable on a validated slice return r > 0 and r < 5373484.5; } comptime { std.debug.assert(classifyTimestamp("2017-12-06T15:36:24.287").? == .date_time); // w1 std.debug.assert(isWriterTimestamp("2017-12-06T15:36:24.287")); // w1 std.debug.assert(classifyTimestamp("2017-12-06").? == .date); // m3 std.debug.assert(classifyTimestamp("15:36:24").? == .time); // m4 std.debug.assert(classifyTimestamp("now").? == .relative); // m5 std.debug.assert(classifyTimestamp("2451545").? == .julian); // m6 std.debug.assert(classifyTimestamp("2017-12-06t15:36:24") == null); // r1 std.debug.assert(classifyTimestamp("-4713-11-24") == null); // r3 boundary std.debug.assert(classifyTimestamp("0") == null); // r3 numeric zero std.debug.assert(!isWriterTimestamp("2017-12-06")); // m8: date alone is not the writer form } // --------------------------------------------------------------------------- // §4.7.2 A Card // --------------------------------------------------------------------------- /// One parsed A card (§4.7.2 attachment card). Grammar: `a-card = "A" SP /// filename SP target [ SP source ] LF` (line 1074). `name` and `target` /// borrow from the caller's `decode_buf`; `source` borrows the card's raw /// args -- no allocation anywhere in this parse. pub const ACard = struct { /// Decoded filename's final path component only; any directory prefix /// is discarded (m1, lines 1084-1086). name: []const u8, /// Decoded target: hash- or wiki-name-shaped (r2, lines 1088-1089). /// Which family it is, and whether it resolves, is crosslink-time work /// (lines 1097-1107) -- out of scope for parse-time acceptance. target: []const u8, /// Raw, never-decoded artifact hash when present; null when absent /// (r3/m2, lines 1093-1095). source: ?[]const u8, }; /// Return the final '/'-delimited element of `name`. `isSimplePathname` /// already rejects an empty element and a trailing '/' (§4.6.1), so the /// element after the last '/' -- or the whole name, if there is none -- is /// always non-empty. fn finalPathComponent(name: []const u8) []const u8 { std.debug.assert(name.len >= 1); var last_slash: ?usize = null; for (name, 0..) |c, i| { // bound: name.len, via the slice iterator if (c == '/') last_slash = i; } const result = if (last_slash) |idx| name[idx + 1 ..] else name; std.debug.assert(result.len >= 1); return result; } comptime { std.debug.assert(std.mem.eql(u8, finalPathComponent("a/b/c"), "c")); std.debug.assert(std.mem.eql(u8, finalPathComponent("x"), "x")); } /// Parse one already-tokenized A card (§4.7.2). Returns null for any /// rejection reason -- bad arity (§4.2.1 line 610: "a fourth on A"), a /// missing filename/target (p1), a bad filename (r1), a bad target (r2), or /// a bad source (r3) -- matching the module's "every rejection collapses to /// null/false" posture. Parse-time acceptance is syntactic only; it does /// not resolve the target to an object (lines 1080-1081). /// /// `decode_buf` is caller-owned and holds both decoded fields. /// Precondition: `decode_buf.len >= filename_token.len + target_token.len` /// (decoded length never exceeds wire length, per `decodeEscapes`). pub fn parseACard(card: CardLine, decode_buf: []u8) ?ACard { std.debug.assert(card.letter == 'A'); var it = card.tokens(); const filename_tok = it.next() orelse return null; // p1: filename required const target_tok = it.next() orelse return null; // p1: target required const source_tok = it.next(); // optional (grammar's "[ SP source ]") if (it.next() != null) return null; // §4.2.1 line 610: "a fourth on A" std.debug.assert(decode_buf.len >= filename_tok.len + target_tok.len); // Filename: decode, then the pathname predicate, then basename (m1). const filename_dec = decodeEscapes(filename_tok, decode_buf[0..filename_tok.len]); if (!isSimplePathname(filename_dec)) return null; // r1 const name = finalPathComponent(filename_dec); // Target: the one hash-bearing position decoded before validation // (§4.3.1 lines 690-692), since it may alternatively hold a wiki name. const target_buf = decode_buf[filename_tok.len..][0..target_tok.len]; const target = decodeEscapes(target_tok, target_buf); const target_ok = validateHashToken(.artifact_hash, target) or isWikiName(target); if (!target_ok) return null; // r2 // Source: raw hash, never decoded (r3; §4.3.1 line 680 names it the // sole exception among hash-bearing positions). Absent -> null (m2). var source: ?[]const u8 = null; if (source_tok) |raw| { if (!validateHashToken(.artifact_hash, raw)) return null; // r3 source = raw; } std.debug.assert(name.len >= 1); std.debug.assert(target.len >= 1); std.debug.assert(source == null or source.?.len == 40 or source.?.len == 64); return .{ .name = name, .target = target, .source = source }; } // --------------------------------------------------------------------------- // §4.7.3 B Card // --------------------------------------------------------------------------- /// One parsed B card (§4.7.3 baseline card). Grammar: `b-card = "B" SP hash /// LF` (line 1127). `hash` is raw -- B is a hash/checksum position and §4.3.1 /// (line 686-688) never decodes it, unlike the A card's target. Presence /// marks the artifact as a delta manifest and the delta-of-delta rejection /// is §8 assembly work, out of scope here (line 1130-1134). pub const BCard = struct { hash: []const u8 }; /// Parse one already-tokenized B card (§4.7.3). Returns null for bad arity /// or a hash failing the hash map (`.artifact_hash`, §4.7.1). pub fn parseBCard(card: CardLine) ?BCard { std.debug.assert(card.letter == 'B'); var it = card.tokens(); const hash_tok = it.next() orelse return null; // p1: hash required if (it.next() != null) return null; // grammar has no second token if (!validateHashToken(.artifact_hash, hash_tok)) return null; // r1 std.debug.assert(hash_tok.len == 40 or hash_tok.len == 64); return .{ .hash = hash_tok }; } // --------------------------------------------------------------------------- // §4.7.4 C Card // --------------------------------------------------------------------------- /// One parsed C card (§4.7.4 comment card). Grammar: `c-card = "C" SP /// comment LF` (line 1140). `comment` borrows the caller's `decode_buf` -- /// the C card is one of the 12 decoded positions (§4.3.1 line 681), and its /// decoded content is otherwise unvalidated (m1). pub const CCard = struct { comment: []const u8 }; /// Parse one already-tokenized C card (§4.7.4). Returns null for a missing /// token, an empty token (`comment = 1*tchar`, Appendix B), or a second /// token -- the grammar has room for exactly one (r1). /// Precondition: `decode_buf.len >= comment token length` (decoded length /// never exceeds wire length, per `decodeEscapes`). pub fn parseCCard(card: CardLine, decode_buf: []u8) ?CCard { std.debug.assert(card.letter == 'C'); var it = card.tokens(); const comment_tok = it.next() orelse return null; // r1: no token if (comment_tok.len == 0) return null; // r1: empty token if (it.next() != null) return null; // grammar has no second token std.debug.assert(decode_buf.len >= comment_tok.len); const comment = decodeEscapes(comment_tok, decode_buf[0..comment_tok.len]); std.debug.assert(comment.len <= comment_tok.len); return .{ .comment = comment }; } // --------------------------------------------------------------------------- // §4.7.5 D Card and E Card // --------------------------------------------------------------------------- /// One parsed D card (§4.7.5 date card). Grammar: `d-card = "D" SP /// timestamp LF` (line 1150). `timestamp` is raw -- D/E timestamps are /// never decoded (§4.3.1 line 687-688). pub const DCard = struct { timestamp: []const u8 }; /// One parsed E card (§4.7.5 technote card). Grammar: `e-card = "E" SP /// timestamp SP technote-id LF` (line 1151). Both fields raw. EVENT-type /// marking is §5.3 type resolution -- deferred to the manifest-assembly /// batch, exactly as A/B's "marks the artifact as..." effects are deferred. pub const ECard = struct { timestamp: []const u8, technote_id: []const u8 }; /// Parse one already-tokenized D card (§4.7.5). Returns null for bad arity /// or a timestamp failing `classifyTimestamp` (§4.6.4). pub fn parseDCard(card: CardLine) ?DCard { std.debug.assert(card.letter == 'D'); var it = card.tokens(); const ts_tok = it.next() orelse return null; // p1: timestamp required if (it.next() != null) return null; // grammar has no second token if (classifyTimestamp(ts_tok) == null) return null; // r2 return .{ .timestamp = ts_tok }; } /// Parse one already-tokenized E card (§4.7.5). Returns null for bad arity, /// a timestamp failing §4.6.4, or a technote-id failing the hash map /// (`.artifact_hash`, §4.7.1) -- the id is a hash/checksum position and is /// never decoded. pub fn parseECard(card: CardLine) ?ECard { std.debug.assert(card.letter == 'E'); var it = card.tokens(); const ts_tok = it.next() orelse return null; // p2: timestamp required const id_tok = it.next() orelse return null; // p2: technote-id required if (it.next() != null) return null; // grammar has no third token if (classifyTimestamp(ts_tok) == null) return null; // r2 if (!validateHashToken(.artifact_hash, id_tok)) return null; // r3 std.debug.assert(id_tok.len == 40 or id_tok.len == 64); return .{ .timestamp = ts_tok, .technote_id = id_tok }; } // --------------------------------------------------------------------------- // §4.7.6 F Card // --------------------------------------------------------------------------- /// §4.7.6-m4/m5 permission-derived entry kind. `x` in the raw perm token /// wins over `l` (checked first); anything else, including an absent perm /// or a hash-less delete marker, is `.regular` (lines 1196-1199). pub const FileMode = enum { regular, executable, symlink }; /// One parsed F card (§4.7.6 file-list entry). `filename` and `old_name` /// borrow the caller's `decode_buf` (decoded, §4.6.1-checked); `hash` and /// `perm` are raw slices of the wire args -- never decoded (m3). `hash == /// null` is the delta-manifest deletion marker (§8.2); whether that's legal /// here depends on a B card elsewhere in the same artifact, which this /// single-card parser can't see -- deferred to the manifest-assembly batch, /// same as B's delta-of-delta check. Sort/dedup and reserved-name dropping /// are per-artifact machinery that already exists, in `FCardAdmission` and /// `isReservedFilename` -- not this parser either. pub const FCard = struct { filename: []const u8, hash: ?[]const u8, perm: ?[]const u8, old_name: ?[]const u8, mode: FileMode, }; /// §4.7.6-m5 classifier: raw perm token -> `FileMode`. `x` anywhere wins; /// else `l` anywhere is a symlink; a null or perm-less token is regular. fn fileMode(perm: ?[]const u8) FileMode { const p = perm orelse return .regular; if (std.mem.indexOfScalar(u8, p, 'x') != null) return .executable; if (std.mem.indexOfScalar(u8, p, 'l') != null) return .symlink; return .regular; } /// Parse one already-tokenized F card (§4.7.6). Returns null for a missing /// filename (r1), a filename or old-name failing the pathname predicate /// (§4.6.1, m1/m2), a hash failing the hash map when present (r3), an empty /// perm token when present (`token = 1*tchar`, Appendix B -- perm is /// unvalidated against {w,x,l} but still must be nonempty), or a 5th token /// (the grammar has room for exactly four positions). /// Precondition: `decode_buf.len >= filename_tok.len + old_tok.len`. pub fn parseFCard(card: CardLine, decode_buf: []u8) ?FCard { std.debug.assert(card.letter == 'F'); var it = card.tokens(); const filename_tok = it.next() orelse return null; // r1: filename required const hash_tok = it.next(); // optional const perm_tok = it.next(); // optional, only meaningful with hash present const old_tok = it.next(); // optional, only meaningful with perm present if (it.next() != null) return null; // grammar has no 5th token std.debug.assert(decode_buf.len >= filename_tok.len + (if (old_tok) |t| t.len else 0)); const filename = decodeEscapes(filename_tok, decode_buf[0..filename_tok.len]); if (!isSimplePathname(filename)) return null; // m1/r2 var hash: ?[]const u8 = null; if (hash_tok) |raw| { if (!validateHashToken(.artifact_hash, raw)) return null; // r3 hash = raw; } // perm: raw, unvalidated against {w,x,l} at parse time (m4), but the // grammar still requires `token = 1*tchar` (Appendix B) -- an empty // 3rd token (e.g. a trailing SP after hash) has no production and // MUST reject, not silently parse as perm=="". if (perm_tok) |raw| { if (raw.len == 0) return null; // p1/r1: no production for empty token } const perm = perm_tok; var old_name: ?[]const u8 = null; if (old_tok) |raw| { const old_buf = decode_buf[filename.len..][0..raw.len]; const decoded = decodeEscapes(raw, old_buf); if (!isSimplePathname(decoded)) return null; // m2/r4 old_name = decoded; } std.debug.assert(filename.len >= 1); std.debug.assert(hash == null or hash.?.len == 40 or hash.?.len == 64); std.debug.assert(perm == null or perm.?.len >= 1); std.debug.assert(old_name == null or old_name.?.len >= 1); return .{ .filename = filename, .hash = hash, .perm = perm, .old_name = old_name, .mode = fileMode(perm), }; } // --------------------------------------------------------------------------- // §4.7.7 G Card, H Card, and I Card // --------------------------------------------------------------------------- /// One parsed G card (§4.7.7 forum thread-root). Grammar: `g-card = "G" SP /// hash LF` (line 1207). `hash` is raw -- a hash/checksum position, never /// decoded (§4.3.1). Presence marks the artifact as a forum post (§5.3 type /// resolution) -- deferred to the manifest-assembly batch, same posture as /// A/B's "marks the artifact as..." effects. pub const GCard = struct { hash: []const u8 }; /// One parsed I card (§4.7.7 forum reply-to). Grammar: `i-card = "I" SP hash /// LF` (line 1209). Byte-identical wire shape to G, raw hash, but distinct /// meaning -- the post being replied to, not the thread root. An artifact /// MUST NOT carry both an H and an I card (line 1218), but that is a /// whole-artifact check a single-card parser cannot see; deferred to the /// §5.4 assembly batch, same as B's delta-of-delta check. pub const ICard = struct { hash: []const u8 }; /// One parsed H card (§4.7.7 forum thread title). Grammar: `h-card = "H" SP /// threadtitle LF` (line 1208). `title` borrows the caller's `decode_buf` -- /// one of the 12 decoded positions (§4.3.1); no content validation beyond /// arity/nonempty applies (line 1216-1217): "any decoded string is /// accepted", unlike the L card's wiki-name gate below. pub const HCard = struct { title: []const u8 }; /// Shared G/I helper: one required raw hash token, validated per the hash /// map (`.artifact_hash`), never decoded. G and I are byte-identical on the /// wire (line 1207, 1209); this is the only place that shape is checked -- /// the two callers below wrap the result into their distinct struct types. fn hashOnlyForumCard(card: CardLine) ?[]const u8 { std.debug.assert(card.letter == 'G' or card.letter == 'I'); var it = card.tokens(); const hash_tok = it.next() orelse return null; // p1: hash required if (it.next() != null) return null; // grammar has no second token if (!validateHashToken(.artifact_hash, hash_tok)) return null; // r1 std.debug.assert(hash_tok.len == 40 or hash_tok.len == 64); return hash_tok; } /// Parse one already-tokenized G card (§4.7.7). Returns null for bad arity /// or a hash failing the hash map. pub fn parseGCard(card: CardLine) ?GCard { std.debug.assert(card.letter == 'G'); const hash = hashOnlyForumCard(card) orelse return null; return .{ .hash = hash }; } /// Parse one already-tokenized I card (§4.7.7). Returns null for bad arity /// or a hash failing the hash map. pub fn parseICard(card: CardLine) ?ICard { std.debug.assert(card.letter == 'I'); const hash = hashOnlyForumCard(card) orelse return null; return .{ .hash = hash }; } /// Parse one already-tokenized H card (§4.7.7). Returns null for a missing /// token, an empty token (`threadtitle = token = 1*tchar`, Appendix B), or a /// second token -- the grammar has room for exactly one. Any nonempty /// decoded content is accepted (line 1216-1217); contrast `parseLCard`. /// Precondition: `decode_buf.len >= title token length`. pub fn parseHCard(card: CardLine, decode_buf: []u8) ?HCard { std.debug.assert(card.letter == 'H'); var it = card.tokens(); const title_tok = it.next() orelse return null; // p1: title required if (title_tok.len == 0) return null; // token = 1*tchar if (it.next() != null) return null; // grammar has no second token std.debug.assert(decode_buf.len >= title_tok.len); const title = decodeEscapes(title_tok, decode_buf[0..title_tok.len]); std.debug.assert(title.len <= title_tok.len); return .{ .title = title }; } // --------------------------------------------------------------------------- // §4.7.8 J Card // --------------------------------------------------------------------------- /// One parsed J card (§4.7.8 ticket field-change). Grammar: `j-card = "J" SP /// fieldname [ SP value ] LF` (line 1222). `field` is the raw wire token, /// stored verbatim including any leading `+` (m1/m2, line 1225-1226) -- not /// a decoded position. `value` is escape-decoded (m1) and borrows the /// caller's `decode_buf`; an absent value is "" per m3, never null -- the /// type is plain `[]const u8`, not `?[]const u8`, which defines the null /// case out of existence rather than modelling it. Ascending-by-field /// ordering across a run of J cards (§4.5.2) is manifest-assembly work, out /// of scope for this single-card parser. Ticket-processing semantics (the /// +-prefix append rule, unknown-field tolerance) are explicitly out of /// scope for the artifact layer (line 1227-1240). pub const JCard = struct { field: []const u8, value: []const u8 }; /// Parse one already-tokenized J card (§4.7.8). Returns null for a missing /// fieldname, an empty fieldname or value token (`token = 1*tchar`, Appendix /// B -- neither `fieldname` nor `value` has its own production, so an empty /// wire token has no production either way), or a third token -- the /// grammar has room for exactly two. /// Precondition: `decode_buf.len >= value token length`. pub fn parseJCard(card: CardLine, decode_buf: []u8) ?JCard { std.debug.assert(card.letter == 'J'); var it = card.tokens(); const field_tok = it.next() orelse return null; // r1: fieldname required if (field_tok.len == 0) return null; // token = 1*tchar const value_tok = it.next(); // optional if (it.next() != null) return null; // grammar has no third token var value: []const u8 = ""; // m3: absent value is "", never null if (value_tok) |raw| { if (raw.len == 0) return null; // token = 1*tchar: no production std.debug.assert(decode_buf.len >= raw.len); value = decodeEscapes(raw, decode_buf[0..raw.len]); } std.debug.assert(field_tok.len >= 1); std.debug.assert(value.len <= if (value_tok) |t| t.len else 0); return .{ .field = field_tok, .value = value }; } // --------------------------------------------------------------------------- // §4.7.9 K Card // --------------------------------------------------------------------------- /// One parsed K card (§4.7.9 ticket card). Grammar: `k-card = "K" SP /// ticket-id LF` (line 1244). `ticket_id` is raw, pinned to the 40-char /// SHA-1 length (r1) -- a 64-char SHA3-length id is always invalid, /// regardless of hash policy. Presence marks the artifact as a ticket /// change (§5.3 type resolution) -- deferred to manifest assembly. pub const KCard = struct { ticket_id: []const u8 }; /// Parse one already-tokenized K card (§4.7.9). Returns null for bad arity /// or an id failing the hash map (`.ticket_id`, §4.7.1) -- pinned to 40 hex, /// lowercase-only (s1: the module takes the parser's MAY as lowercase-only, /// mirroring the §4.7.1 hash-map decision at line 1648). pub fn parseKCard(card: CardLine) ?KCard { std.debug.assert(card.letter == 'K'); var it = card.tokens(); const id_tok = it.next() orelse return null; // p1: ticket-id required if (it.next() != null) return null; // grammar has no second token if (!validateHashToken(.ticket_id, id_tok)) return null; // r1 std.debug.assert(id_tok.len == 40); return .{ .ticket_id = id_tok }; } // --------------------------------------------------------------------------- // §4.7.10 L Card // --------------------------------------------------------------------------- /// One parsed L card (§4.7.10 wiki-title card). Grammar: `l-card = "L" SP /// wikititle LF` (line 1256). `title` is escape-decoded and borrows the /// caller's `decode_buf`; unlike H, it MUST pass the wiki-name predicate /// (§4.6.2) -- this is the H/L contrast the RFC calls out at line 1217. /// Presence marks the artifact as a wiki page (§5.3) -- deferred. pub const LCard = struct { title: []const u8 }; /// Parse one already-tokenized L card (§4.7.10). Returns null for a missing /// token or a decoded title failing `isWikiName` (§4.6.2) -- which subsumes /// the empty-token and length-bound cases (wiki-name rejects length outside /// 1..100), so no separate `token = 1*tchar` check is needed here. A second /// token also rejects -- the grammar has room for exactly one. /// Precondition: `decode_buf.len >= title token length`. pub fn parseLCard(card: CardLine, decode_buf: []u8) ?LCard { std.debug.assert(card.letter == 'L'); var it = card.tokens(); const title_tok = it.next() orelse return null; // p1: title required if (it.next() != null) return null; // grammar has no second token std.debug.assert(decode_buf.len >= title_tok.len); const title = decodeEscapes(title_tok, decode_buf[0..title_tok.len]); if (!isWikiName(title)) return null; // r1 std.debug.assert(isWikiName(title)); return .{ .title = title }; } // --------------------------------------------------------------------------- // §4.7.11 M Card // --------------------------------------------------------------------------- /// One parsed M card (§4.7.11 cluster-member card). Grammar: `m-card = "M" /// SP hash LF` (line 1263). `hash` is raw -- a hash/checksum position, never /// decoded (§4.3.1). Repeatable; the strict-ascending-by-hash run check /// (line 1266) is §4.5.2 manifest-assembly work, already tested there. /// Presence marks the artifact as a cluster (§5.3 type resolution) -- /// deferred, same posture as A/B/G. pub const MCard = struct { hash: []const u8 }; /// Parse one already-tokenized M card (§4.7.11). Returns null for bad arity /// or a hash failing the hash map (`.artifact_hash`, §4.7.1). pub fn parseMCard(card: CardLine) ?MCard { std.debug.assert(card.letter == 'M'); var it = card.tokens(); const hash_tok = it.next() orelse return null; // p1: hash required if (it.next() != null) return null; // grammar has no second token if (!validateHashToken(.artifact_hash, hash_tok)) return null; // r1 std.debug.assert(hash_tok.len == 40 or hash_tok.len == 64); return .{ .hash = hash_tok }; } // --------------------------------------------------------------------------- // §4.7.12 N Card // --------------------------------------------------------------------------- /// One parsed N card (§4.7.12 mimetype card). Grammar: `n-card = "N" SP /// mimetype LF` (line 1270). `mimetype` is escape-decoded and borrows the /// caller's `decode_buf` -- one of the 12 decoded positions (§4.3.1); no /// mime-syntax validation applies (line 1272). Per-type default/target /// (text/x-fossil-wiki on wiki, text/plain on attachment, inert on /// check-in) is crosslink-time semantics -- deferred to manifest assembly. pub const NCard = struct { mimetype: []const u8 }; /// Parse one already-tokenized N card (§4.7.12). Returns null for a missing /// token, an empty token (`mimetype = token = 1*tchar`, Appendix B), or a /// second token -- the grammar has room for exactly one. /// Precondition: `decode_buf.len >= mimetype token length`. pub fn parseNCard(card: CardLine, decode_buf: []u8) ?NCard { std.debug.assert(card.letter == 'N'); var it = card.tokens(); const tok = it.next() orelse return null; // r1: no token if (tok.len == 0) return null; // r1: empty token if (it.next() != null) return null; // grammar has no second token std.debug.assert(decode_buf.len >= tok.len); const mimetype = decodeEscapes(tok, decode_buf[0..tok.len]); std.debug.assert(mimetype.len >= 1 and mimetype.len <= tok.len); return .{ .mimetype = mimetype }; } // --------------------------------------------------------------------------- // §4.7.13 P Card // --------------------------------------------------------------------------- /// One parsed P card (§4.7.13 parent card). Grammar: `p-card = "P" *( SP /// hash ) LF` (line 1290). `parents` borrows the raw wire arg bytes (never /// decoded -- a hash/checksum position); zero tokens (bare `P\n`) MUST be /// accepted (line 1293-1295) and yields `parents == null`, `count == 0`. /// Tokens are neither sorted nor deduplicated -- order is semantic (line /// 1296). Per-type meaning (primary-ancestor + merge-parents on a /// check-in, edit-chain link on a technote, ...) is manifest-assembly work. pub const PCard = struct { parents: ?[]const u8, count: usize, /// Re-tokenize the already-validated raw arg bytes into hashes, in /// wire order. Cheap: no allocation, `TokenIterator` borrows. pub fn hashes(self: PCard) TokenIterator { return .{ .rest = self.parents }; } }; /// Parse one already-tokenized P card (§4.7.13). Returns null iff any token /// fails the hash map (`.artifact_hash`, §4.7.1) -- zero tokens is not a /// failure (m1). pub fn parsePCard(card: CardLine) ?PCard { std.debug.assert(card.letter == 'P'); var count: usize = 0; var it = card.tokens(); // Bounded by card.args.len: each next() call strictly shrinks // it.rest, so this loop terminates within card.args.len iterations. while (it.next()) |tok| { if (!validateHashToken(.artifact_hash, tok)) return null; // r1 count += 1; } std.debug.assert(card.args != null or count == 0); return .{ .parents = card.args, .count = count }; } // --------------------------------------------------------------------------- // §4.7.14 Q Card // --------------------------------------------------------------------------- /// §4.7.14 q-sign: the byte glued onto the first hash token, distinguishing /// a cherry-pick from a backout (`q-sign = "+" / "-"`, line 1327). pub const QSign = enum { cherrypick, backout }; /// One parsed Q card (§4.7.14 cherry-pick card). Grammar: `q-card = "Q" SP /// q-sign hash [ SP hash ] LF` (line 1325-1326). `target` is the sign- /// stripped first-token remainder, raw, validated per the hash map; /// `baseline` is the optional plain unprefixed second token, raw. An /// absent baseline is left `null`, not resolved to the primary parent /// (m3, line 1334-1336) -- that resolution is manifest-assembly work. pub const QCard = struct { sign: QSign, target: []const u8, baseline: ?[]const u8 }; /// Parse one already-tokenized Q card (§4.7.14). Returns null for a /// missing/third token, a first byte other than '+' or '-' (q-sign, line /// 1327), or a remainder/baseline failing the hash map. pub fn parseQCard(card: CardLine) ?QCard { std.debug.assert(card.letter == 'Q'); var it = card.tokens(); const first = it.next() orelse return null; // r2: token required const baseline_tok = it.next(); // optional if (it.next() != null) return null; // r2: grammar has no third token // token = 1*tchar (Appendix B) guarantees first.len >= 1 on the wire, // but an argument-less-looking empty token (`Q \n`) still reaches // here as one empty token -- guard before indexing the sign byte. if (first.len == 0) return null; // r1 const sign: QSign = switch (first[0]) { '+' => .cherrypick, '-' => .backout, else => return null, // r1: q-sign is "+" / "-" only }; const target = first[1..]; if (!validateHashToken(.artifact_hash, target)) return null; // r3 var baseline: ?[]const u8 = null; if (baseline_tok) |raw| { if (!validateHashToken(.artifact_hash, raw)) return null; // r3 baseline = raw; } std.debug.assert(target.len == 40 or target.len == 64); std.debug.assert(baseline == null or baseline.?.len == 40 or baseline.?.len == 64); return .{ .sign = sign, .target = target, .baseline = baseline }; } // --------------------------------------------------------------------------- // Manifest parsing (Sections 4, 5) — TODO // --------------------------------------------------------------------------- pub const Manifest = struct { // Populated by parse(): cards, type, name. Skeleton for now. kind: ArtifactType = .other, }; pub const ParseError = error{ SyntaxError, NotImplemented }; /// Parse a structural artifact from its bytes. See Section 4 (card syntax), /// Section 5 (types + per-type validity). Not yet implemented. pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) ParseError!Manifest { _ = allocator; _ = bytes; return ParseError.NotImplemented; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test "artifact §3.1: name is SHA3-256 of exact raw bytes" { var out: [64]u8 = undefined; // §A.3 / Table 8 (RFC line 2584): alpha.txt's content is "hello world\n", // named by the SHA3-256 given as its F-card hash. const name = artifactName(.sha3_256, "hello world\n", &out); try std.testing.expectEqualStrings( "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138", name, ); } // The §A.1.1 SHA-1 worked-example manifest: 127 raw bytes (27+22+2+35+6+35 // per the §A.2 Z-card offset table), literal backslash-s escapes retained // exactly as printed in the RFC. Its stated name is // a28c83647dfa805f05f3204a7e146eb1f0d90505 (RFC line 2426). const a1_1_manifest = @embedFile("rfc_vectors/artifact_a1_1_sha1_manifest.txt"); const a1_1_name = "a28c83647dfa805f05f3204a7e146eb1f0d90505"; test "artifact §3.2: 40-hex name is SHA-1 over raw bytes" { try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len); var out: [64]u8 = undefined; const name = artifactName(.sha1, a1_1_manifest, &out); try std.testing.expectEqualStrings(a1_1_name, name); } test "artifact §3.2: name length selects algorithm" { try std.testing.expectEqual(HashAlgo.sha1, nameAlgo(40).?); try std.testing.expectEqual(HashAlgo.sha3_256, nameAlgo(64).?); } test "artifact §3.2: name of any other length is rejected" { const bad_lens = [_]usize{ 0, 4, 39, 41, 63, 65, 128 }; for (bad_lens) |len| { try std.testing.expect(nameAlgo(len) == null); // Fabricate an all-'a' token of that length: rejection here must be // on length alone, before any character-class check (§3.2-r1). var buf: [128]u8 = undefined; std.debug.assert(len <= buf.len); @memset(buf[0..len], 'a'); try std.testing.expect(!validateName(buf[0..len])); } } test "artifact §3.3: computed name is lowercase hex" { var out: [64]u8 = undefined; const sha1_name = artifactName(.sha1, a1_1_manifest, &out); for (sha1_name) |c| try std.testing.expect(isHexLower(c)); var out2: [64]u8 = undefined; const sha3_name = artifactName(.sha3_256, "hello world\n", &out2); for (sha3_name) |c| try std.testing.expect(isHexLower(c)); } test "artifact §3.3: validation rejects uppercase, does not normalize" { // Same fixture as the rejection test above, restated to make the point // explicit: a normalize-then-accept parser would flip this to true. var upper: [40]u8 = undefined; for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); try std.testing.expect(!validateName(&upper)); // And the lowercase original still validates, proving the reject is // case-specific rather than a blanket failure. try std.testing.expect(validateName(a1_1_name)); } test "artifact §3.4: verify dispatches on length" { try std.testing.expect(verify(a1_1_name, a1_1_manifest)); try std.testing.expect(verify( "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138", "hello world\n", )); } test "artifact §3.4: uppercase name never verifies" { var upper: [40]u8 = undefined; for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); try std.testing.expect(!verify(&upper, a1_1_manifest)); } test "artifact §3.4: one undifferentiated failure signal" { // Wrong length. try std.testing.expect(!verify("deadbeef10", a1_1_manifest)); // Non-hex character at a valid (40) length. var non_hex: [40]u8 = undefined; @memcpy(&non_hex, a1_1_name); non_hex[0] = 'g'; try std.testing.expect(!verify(&non_hex, a1_1_manifest)); // Valid-form 40-hex name, wrong digest (mismatched content). try std.testing.expect(!verify(a1_1_name, "hello world\n")); } test "artifact §3.5: ABNF canonical grammar accepts lhexdig only" { var boundary: [40]u8 = undefined; @memset(&boundary, '0'); try std.testing.expect(validateName(&boundary)); @memset(&boundary, '9'); try std.testing.expect(validateName(&boundary)); @memset(&boundary, 'a'); try std.testing.expect(validateName(&boundary)); @memset(&boundary, 'f'); try std.testing.expect(validateName(&boundary)); var has_g: [40]u8 = undefined; @memset(&has_g, 'a'); has_g[39] = 'g'; try std.testing.expect(!validateName(&has_g)); var has_upper: [40]u8 = undefined; @memset(&has_upper, 'a'); has_upper[0] = 'A'; try std.testing.expect(!validateName(&has_upper)); var sha3_all_f: [64]u8 = undefined; @memset(&sha3_all_f, 'f'); try std.testing.expect(validateName(&sha3_all_f)); } test "artifact §3.6: prefix resolves and lowercases uppercase input" { const names = [_][]const u8{a1_1_name}; switch (try resolvePrefix("a28c", &names)) { .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx), else => return error.TestUnexpectedResult, } switch (try resolvePrefix("A28C", &names)) { .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx), else => return error.TestUnexpectedResult, } } test "artifact §3.6: prefix length outside 4..64 is invalid" { const names = [_][]const u8{a1_1_name}; try std.testing.expectError(error.InvalidPrefix, resolvePrefix("a28", &names)); var too_long: [65]u8 = undefined; @memset(&too_long, 'a'); try std.testing.expectError(error.InvalidPrefix, resolvePrefix(&too_long, &names)); try std.testing.expectError(error.InvalidPrefix, resolvePrefix("zzzz", &names)); } test "artifact §3.6: ambiguous prefix, incl. cross-algorithm collision" { // Cross-algorithm: one SHA-1-named, one SHA3-named, sharing "a28c". const cross = [_][]const u8{ a1_1_name, "a28c000000000000000000000000000000000000000000000000000000000000", }; try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &cross)); // Same-algorithm: two 40-char names sharing a prefix, reported the same. const same = [_][]const u8{ a1_1_name, "a28c000000000000000000000000000000000000", }; try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &same)); } 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 "self-checksum = lowercase MD5 (empty input vector)" { 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); } // --------------------------------------------------------------------------- // §4.2 Line and Token Grammar / §4.2.2 Closed Card Alphabet — tests // --------------------------------------------------------------------------- test "artifact §4.2.2: exactly the 21 defined letters are accepted" { // Lines 622-624: "Exactly 21 card letters are defined: A, B, C, D, E, F, // G, H, I, J, K, L, M, N, P, Q, R, T, U, W, Z." Closed set, no // forward-compat (lines 626-627). const undefined_in_range = "OSVXY"; 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, 21), 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 S V X Y are rejected" { // Line 625: "an in-range but undefined letter (O, S, V, X, Y) MUST be // rejected." const table = [_]u8{ 'O', 'S', '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); } // --------------------------------------------------------------------------- // §4.3 Character-Escape Encoding — tests // --------------------------------------------------------------------------- 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)); } // --------------------------------------------------------------------------- // §4.4 Card Ordering — tests // --------------------------------------------------------------------------- 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)); } // --------------------------------------------------------------------------- // §4.5 Occurrence Classes and Intra-Type Ordering — tests // --------------------------------------------------------------------------- test "artifact §4.5.1: card letters partition into single / repeatable / neither" { // Lines 753-766: 14 single-occurrence, 6 repeatable, 1 (Z) neither — // 21 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, 6), 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 } // --------------------------------------------------------------------------- // §4.6.4 Timestamps // --------------------------------------------------------------------------- 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: "A bare decimal number, treated as a Julian day number when its // value r satisfies 0 <= r < 5373484.5." (r3 tightens the lower bound // to strictly greater than zero; covered separately below.) 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)); } // --------------------------------------------------------------------------- // §4.7.2 A Card // --------------------------------------------------------------------------- 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-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 §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); } // --------------------------------------------------------------------------- // §4.7.3 B Card // --------------------------------------------------------------------------- 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); } // --------------------------------------------------------------------------- // §4.7.4 C Card // --------------------------------------------------------------------------- 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); } // --------------------------------------------------------------------------- // §4.7.5 D Card and E Card // --------------------------------------------------------------------------- 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 } // --------------------------------------------------------------------------- // §4.7.6 F Card // --------------------------------------------------------------------------- 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); } 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-m5: perm x wins over l; else l is symlink; else regular" { // Lines 1196-1199. const cases = [_]struct { perm: ?[]const u8, want: FileMode }{ .{ .perm = "x", .want = .executable }, .{ .perm = "l", .want = .symlink }, .{ .perm = "w", .want = .regular }, .{ .perm = "xl", .want = .executable }, // x wins .{ .perm = "wl", .want = .symlink }, .{ .perm = "", .want = .regular }, .{ .perm = null, .want = .regular }, .{ .perm = "abc", .want = .regular }, }; for (cases) |c| { try std.testing.expectEqual(c.want, fileMode(c.perm)); } } 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); } // --------------------------------------------------------------------------- // §4.7.7 G Card, H Card, and I Card // --------------------------------------------------------------------------- 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); } // artifact §4.7.7-hi-r1: "An artifact MUST NOT contain both an H card and an // I card" (line 1218) is a §5.4 whole-artifact check -- a single-card parser // sees one card at a time and has no visibility into its siblings. Deferred // to the manifest-assembly batch, same boundary as B's delta-of-delta check // (see BCard's doc comment above). No test here: there is no enforcement // point at this layer to exercise. // --------------------------------------------------------------------------- // §4.7.8 J Card // --------------------------------------------------------------------------- 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 } // --------------------------------------------------------------------------- // §4.7.9 K Card // --------------------------------------------------------------------------- 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); } // --------------------------------------------------------------------------- // §4.7.10 L Card // --------------------------------------------------------------------------- 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 } // --------------------------------------------------------------------------- // §4.7.11 M Card // --------------------------------------------------------------------------- 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); } } // --------------------------------------------------------------------------- // §4.7.12 N Card // --------------------------------------------------------------------------- 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); } // --------------------------------------------------------------------------- // §4.7.13 P Card // --------------------------------------------------------------------------- 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); } // --------------------------------------------------------------------------- // §4.7.14 Q Card // --------------------------------------------------------------------------- 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); } } fn parseNoLeak(allocator: std.mem.Allocator, bytes: []const u8) !void { if (parse(allocator, bytes)) |_| {} else |err| switch (err) { ParseError.SyntaxError, ParseError.NotImplemented => {}, } } test "artifact §4.2: parse propagates allocation failure (arena-per-parse)" { try std.testing.checkAllAllocationFailures(std.testing.allocator, parseNoLeak, .{a1_1_manifest}); } // --------------------------------------------------------------------------- // Seeded deterministic fuzz / property tests. Same posture as delta.zig's: // a fixed seed drives every draw, so a failure reproduces exactly and each // block runs under plain `zig build test`. The parameterized targets in // artifact_fuzz.zig cover the same ground at a caller-chosen seed/round // count for the out-of-band runner. // --------------------------------------------------------------------------- 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. _ = parse(a, buf[0..len]) catch {}; } } 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); } } } } 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))); } }