//! 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 (§5.1 Table 3). Every structural artifact
/// classifies as exactly one of these eight; the backing integer is the
/// stable numeric code from Table 3. Code 0 ("not yet determined") is not
/// a variant here -- it is a transient parser state modelled as the
/// absence of an `ArtifactType` value (`?ArtifactType`), never as a value
/// of this enum (§5.1-m1).
pub const ArtifactType = enum(u8) {
manifest = 1, // a check-in
cluster = 2, // a set of artifact hashes (sync housekeeping)
control = 3, // a tag/branch mutation
wiki = 4,
ticket = 5,
attachment = 6,
event = 7, // a technote
forum = 8, // a forum post
/// §5.1 Table 3 external name -- the name emitted on machine-readable
/// (JSON/API) surfaces. Distinct from the informative diagnostic
/// vocabulary the RFC explicitly declines to normatize (manifest and
/// "forum post" with a space); that vocabulary is not implemented.
pub fn externalName(self: ArtifactType) []const u8 {
return switch (self) {
.manifest => "checkin",
.cluster => "cluster",
.control => "tag",
.wiki => "wiki",
.ticket => "ticket",
.attachment => "attachment",
.event => "technote",
.forum => "forumpost",
};
}
};
/// 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);
}
// ---------------------------------------------------------------------------
// Section 7 -- Hash Policy
// ---------------------------------------------------------------------------
//
// Pure primitives only. The repository inventory scan (which names exist),
// the persisted policy setting, and the store-lookup used for
// deduplication are all effectful and belong to the store/repostate layer;
// this module models §7's decisions as total functions over injected
// booleans and lengths.
/// §7.1 Table 7 hash-policy values. The backing integer is the ordinal
/// column: "0 through 4, in the order listed" (§7.1-w1).
pub const HashPolicy = enum(u3) { sha1 = 0, auto = 1, sha3 = 2, sha3_only = 3, shun_sha1 = 4 };
/// §7.1 Table 7 "Value" column -- the one comptime source of truth for the
/// wire strings, shared by `policyName` (encode) and `policyFromString`
/// (parse) so the five spellings are never transcribed twice.
const policy_value_string = std.EnumArray(HashPolicy, []const u8).init(.{
.sha1 = "sha1",
.auto = "auto",
.sha3 = "sha3",
.sha3_only = "sha3-only",
.shun_sha1 = "shun-sha1",
});
/// Encode a policy as its Table 7 value string.
pub fn policyName(p: HashPolicy) []const u8 {
return policy_value_string.get(p);
}
/// Parse a Table 7 value string. Matching is case-sensitive and exact
/// (§7.1-p1); an unrecognized value is `null`, which the caller treats as
/// fatal (§7.1-r1). Bounded by the fixed 5-entry policy table.
pub fn policyFromString(s: []const u8) ?HashPolicy {
for (std.enums.values(HashPolicy)) |tag| {
if (std.mem.eql(u8, s, policy_value_string.get(tag))) return tag;
}
return null;
}
/// §7.1 Table 7 "Primary hash" column: the algorithm that names newly
/// stored content under `p`.
pub fn primaryHash(p: HashPolicy) HashAlgo {
return switch (p) {
.sha1, .auto => .sha1, // §7.1-m1
.sha3, .sha3_only, .shun_sha1 => .sha3_256, // §7.1-m2
};
}
/// §7.1 Table 7 "Alternative hash" column. `null` means the policy
/// disallows the alternative hash outright: a request for it MUST fail
/// with an empty result, never a hash value (§7.1-r2).
pub fn alternativeHash(p: HashPolicy) ?HashAlgo {
return switch (p) {
.sha1, .auto => .sha3_256, // §7.1-m3
.sha3 => .sha1, // §7.1-m3
.sha3_only, .shun_sha1 => null, // §7.1-r2
};
}
/// §7.2 default-policy derivation for a repository with no assigned hash-
/// policy setting. `has_sha1_named`/`has_sha3_named` are the caller's
/// inventory-scan result (a name over 40 hex digits is SHA3-named, exactly
/// 40 is SHA1-named -- see `nameAlgo`).
pub fn defaultPolicy(has_sha1_named: bool, has_sha3_named: bool) HashPolicy {
// §7.2-m1: any SHA3 name, or no SHA-1 name at all (including empty).
const r: HashPolicy = if (has_sha3_named or !has_sha1_named) .sha3 else .auto; // §7.2-m2 otherwise
std.debug.assert(r == .sha3 or r == .auto); // §7.2-m3: never shun-sha1
return r;
}
/// §7.3 set-time coercion of an explicit policy assignment. Only `auto` is
/// ever rewritten, and only when a SHA3 name already exists (§7.3-m2,
/// "set-time coercion"); every other requested value -- including an
/// explicit demotion back to `sha1` -- passes through unchanged (§7.3-m1,
/// "no demotion guard").
pub fn coerceAssignment(requested: HashPolicy, has_sha3_named: bool) HashPolicy {
const r: HashPolicy = if (requested == .auto and has_sha3_named) .sha3 else requested;
std.debug.assert(requested == .auto or r == requested);
return r;
}
/// §7.4 ratchet decision for one content-storage write under `effective`.
/// `selected_name` is the name already chosen for this write -- computed
/// or caller-supplied (§7.4-m2, selection precedes the ratchet check).
/// Promotion triggers only out of `auto`, and only once the selected name
/// exceeds the 40-character SHA-1 length (§7.4-m1).
pub fn ratchet(effective: HashPolicy, selected_name: []const u8) HashPolicy {
std.debug.assert(nameAlgo(selected_name.len) != null); // a legal 40/64 name
const r: HashPolicy = if (effective == .auto and selected_name.len > 40) .sha3 else effective;
std.debug.assert(r == effective or (effective == .auto and r == .sha3));
return r;
}
/// §7.5 naming algorithm for content stored without an externally supplied
/// name. `alternative_present` is the caller's store-lookup result for the
/// alternative-algorithm hash. Tries the alternative first where the
/// policy permits one, falling back to the primary hash on a miss --
/// which is also where sha3-only/shun-sha1 land, their disallowed
/// alternative request absorbed silently as if it had missed (§7.5-p1,
/// §7.5-r1).
pub fn dedupNameAlgo(p: HashPolicy, alternative_present: bool) HashAlgo {
const r: HashAlgo = blk: {
if (alternativeHash(p)) |alt| {
if (alternative_present) break :blk alt;
}
break :blk primaryHash(p);
};
if (alternativeHash(p)) |alt| {
std.debug.assert(r == primaryHash(p) or r == alt);
} else {
std.debug.assert(r == primaryHash(p));
}
return r;
}
/// §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;
}
// ---------------------------------------------------------------------------
// §6.3 R Card: Aggregate Content Checksum
// ---------------------------------------------------------------------------
/// §6.3.2 one tracked file's contribution to the R aggregate: pathname and
/// raw content. The file's size (§6.3.2 step 2) is `content.len`.
pub const TrackedFile = struct { name: []const u8, content: []const u8 };
/// Decimal digits needed for the largest representable `usize` size header
/// (§6.3.2 step 2 bound): a 64-bit count needs at most 20 digits.
const max_size_digits = 20;
comptime {
std.debug.assert(std.math.maxInt(u64) < std.math.pow(u128, 10, max_size_digits));
}
/// Feed one file's §6.3.2 step-2 size header into the running MD5 context:
/// a single space, then `size` as minimal decimal ASCII (no leading zeros,
/// no sign, no separators -- §6.3.2-m1), then LF. Manual digit extraction
/// into a bounded stack buffer avoids `bufPrint`'s `catch unreachable`
/// (forbidden outside test code).
fn updateSizeHeader(ctx: *std.crypto.hash.Md5, size: usize) void {
var digits: [max_size_digits]u8 = undefined;
var n_digits: usize = 0;
var v = size;
while (true) {
std.debug.assert(n_digits < digits.len);
digits[n_digits] = @as(u8, @intCast(v % 10)) + '0';
n_digits += 1;
v /= 10;
if (v == 0) break;
}
std.debug.assert(n_digits >= 1 and n_digits <= digits.len); // "0" for size 0
var header: [1 + max_size_digits + 1]u8 = undefined;
var w: usize = 0;
header[w] = ' ';
w += 1;
var i: usize = n_digits;
while (i > 0) {
i -= 1;
std.debug.assert(w < header.len);
header[w] = digits[i]; // most-significant digit first
w += 1;
}
std.debug.assert(w < header.len);
header[w] = '\n';
w += 1;
std.debug.assert(w <= header.len);
ctx.update(header[0..w]);
}
/// §6.3 R-card aggregate content checksum. Lowercase-hex MD5 over, for each
/// tracked file in canonical filename order, name || SP || minimal-decimal
/// (size) || LF || content (§6.3.2). `files` MUST already be in §4.5.3
/// canonical ascending order (the F-card ordering, §4.5.2) -- asserted, not
/// sorted here (ordering is owned by manifest assembly; re-sorting here
/// would duplicate that mechanism). Allocation-free; writes 32 lowercase hex
/// bytes to `out` and returns the slice. Delta manifests (§6.3.3, out of
/// batch scope) resolve their merged file set in the caller before calling
/// this.
pub fn aggregateChecksum(files: []const TrackedFile, out: *[32]u8) []u8 {
if (files.len > 1) {
for (1..files.len) |i| {
std.debug.assert(compareBytes(files[i - 1].name, files[i].name) == .lt);
}
}
var ctx = std.crypto.hash.Md5.init(.{});
for (files) |f| {
ctx.update(f.name);
updateSizeHeader(&ctx, f.content.len);
ctx.update(f.content);
}
var digest: [16]u8 = undefined;
ctx.final(&digest);
const hex = std.fmt.bytesToHex(digest, .lower);
@memcpy(out, &hex);
for (out) |c| std.debug.assert(isHexLower(c));
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, one layer up. `parse` calls `frame` as its entry seam and enforces
/// it structurally: the §4.4 order/§4.5.1 occurrence gates make a Z card
/// (order-terminal, single-occurrence) refuse everything that could follow
/// it, so trailing bytes past the last legitimate card always reject.
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.3 inverse of `escapeOctet`: the two-char escape successor for a raw
/// octet that Table 1 names, or null for any other byte. Same eight pairs
/// as `escapeOctet`, reversed.
fn escapeSuccessor(c: u8) ?u8 {
return switch (c) {
0x0A => 'n', // LF
0x20 => 's', // SP
0x09 => 't', // TAB
0x0D => 'r', // CR
0x0B => 'v', // VT
0x0C => 'f', // FF
0x00 => '0', // NUL
0x5C => '\\', // backslash
else => null,
};
}
comptime {
std.debug.assert(escapeSuccessor(0x0A).? == 'n');
std.debug.assert(escapeSuccessor(0x20).? == 's');
std.debug.assert(escapeSuccessor(0x00).? == '0');
std.debug.assert(escapeSuccessor(0x5C).? == '\\');
std.debug.assert(escapeSuccessor('x') == null);
}
/// §4.3 in-place escape ENCODE of one free-text token -- the writer-side
/// inverse of `decodeEscapes`. Writes the encoded bytes into `out` and
/// returns the used prefix: each of Table 1's eight named octets becomes
/// the two-byte pair `\<succ>`; every other byte passes through verbatim.
/// Precondition: `out.len >= 2 * token.len` -- an escaped byte doubles,
/// nothing shrinks, so this bound is always sufficient.
pub fn encodeEscapes(token: []const u8, out: []u8) []u8 {
std.debug.assert(out.len >= 2 * token.len);
var w: usize = 0;
for (token) |c| { // bound: token.len iterations, each writes <= 2 bytes
std.debug.assert(w <= 2 * token.len);
if (escapeSuccessor(c)) |succ| {
out[w] = '\\';
out[w + 1] = succ;
w += 2;
} else {
out[w] = c;
w += 1;
}
}
std.debug.assert(w <= 2 * token.len);
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 (L1014) 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` directly.
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 };
}
// ---------------------------------------------------------------------------
// §4.7.15 R Card
// ---------------------------------------------------------------------------
/// One parsed R card (§4.7.15 repository-checksum card). Grammar: `r-card =
/// "R" SP md5-token LF`. `checksum` is raw -- a hash/checksum position,
/// never decoded (§4.7.1). An R card may appear only in a manifest (§5.2)
/// and is never required there -- both deferred to manifest assembly, same
/// posture as A/B/G/M.
pub const RCard = struct { checksum: []const u8 };
/// Parse one already-tokenized R card (§4.7.15). Returns null for bad arity
/// or a checksum failing the hash map (`.checksum`, §4.7.1: exactly 32
/// case-insensitive hex).
pub fn parseRCard(card: CardLine) ?RCard {
std.debug.assert(card.letter == 'R');
var it = card.tokens();
const tok = it.next() orelse return null; // p1: checksum required
if (it.next() != null) return null; // r1: grammar has no second token
if (!validateHashToken(.checksum, tok)) return null;
std.debug.assert(tok.len == 32);
return .{ .checksum = tok };
}
// ---------------------------------------------------------------------------
// §4.7.16 T Card
// ---------------------------------------------------------------------------
/// §4.7.16 tag-sign: the byte glued onto the tagname, selecting the tag
/// operation (`tag-sign = "+" / "-" / "*"`). `+` adds, `-` cancels, `*`
/// propagates to descendants -- crosslink semantics deferred (§5.4).
pub const TSign = enum { add, cancel, propagate };
/// One parsed T card (§4.7.16 tag card). Grammar: `t-card = "T" SP tag-sign
/// tagname SP ( hash / "*" ) [ SP value ] LF` (lines 1360-1362). `name` is
/// the escape-decoded, sign-stripped tagname remainder; `target` is the raw,
/// never-decoded hash, or null for the literal `*` self-referential tag
/// (p2); `value` is escape-decoded and null iff absent -- an absent value is
/// null (m1), unlike the J card's empty-string default. Repeatable; the
/// two-level-ascending-by-(name,target) run check (§4.5.2) is manifest-
/// assembly work, already tested there.
pub const TCard = struct {
sign: TSign,
name: []const u8,
target: ?[]const u8,
value: ?[]const u8,
};
/// `s` is the tagname after the sign byte is stripped (`tagname = tag-sign
/// token`, App. B) -- non-empty per grammar, and MUST NOT consist entirely
/// of hexadecimal digits at any length (§4.7.16-r2), so a tag name stays
/// distinguishable from a hash prefix. Case-insensitive per the hexdig-ci
/// production a hash prefix itself uses (§3.6).
fn isAllHexCi(s: []const u8) bool {
std.debug.assert(s.len >= 1);
for (s) |c| {
if (!isHexCi(c)) return false;
}
return true;
}
/// Parse one already-tokenized T card (§4.7.16). Returns null for bad arity
/// (r4), a decoded name that is empty or not `+`/`-`/`*`-prefixed (r1), a
/// remainder that is entirely hex digits (r2), or a target failing `*` / the
/// hash map (r3).
/// Precondition: `decode_buf.len >= name_tok.len + (value_tok orelse "").len`.
pub fn parseTCard(card: CardLine, decode_buf: []u8) ?TCard {
std.debug.assert(card.letter == 'T');
var it = card.tokens();
const name_tok = it.next() orelse return null; // p1: name required
const target_tok = it.next() orelse return null; // p1: target required
const value_tok = it.next(); // optional
if (it.next() != null) return null; // r4: grammar has no fourth token
std.debug.assert(decode_buf.len >= name_tok.len);
const name_decoded = decodeEscapes(name_tok, decode_buf[0..name_tok.len]);
if (name_decoded.len == 0) return null; // r1: needs at least the sign byte
const sign: TSign = switch (name_decoded[0]) {
'+' => .add,
'-' => .cancel,
'*' => .propagate,
else => return null, // r1: tag-sign is "+" / "-" / "*" only
};
const remainder = name_decoded[1..];
if (remainder.len == 0) return null; // tagname = tag-sign token, token = 1*tchar
if (isAllHexCi(remainder)) return null; // r2: stays distinguishable from a hash prefix
// m3: target is never decoded -- "*" here means the literal wire byte.
var target: ?[]const u8 = null;
if (!std.mem.eql(u8, target_tok, "*")) {
if (!validateHashToken(.artifact_hash, target_tok)) return null; // r3
target = target_tok;
}
var value: ?[]const u8 = null; // m1: absent value is null, not ""
if (value_tok) |raw| {
std.debug.assert(decode_buf.len >= name_tok.len + raw.len);
value = decodeEscapes(raw, decode_buf[name_tok.len..][0..raw.len]); // m2
}
std.debug.assert(remainder.len >= 1);
std.debug.assert(target == null or target.?.len == 40 or target.?.len == 64);
return .{ .sign = sign, .name = remainder, .target = target, .value = value };
}
// ---------------------------------------------------------------------------
// §4.7.17 U Card
// ---------------------------------------------------------------------------
/// Anonymous-login substitute (§4.7.17, lines 1416-1419): a bare `U` line or
/// a `U` followed by an empty token both yield this literal, never null or
/// "".
const ANONYMOUS_LOGIN = "anonymous";
/// One parsed U card (§4.7.17 user card). Grammar: `u-card = "U" [ SP
/// [ login ] ] LF`. `login` is the escape-decoded login token, or
/// `ANONYMOUS_LOGIN` when the token is absent or empty (m1) -- this card's
/// grammar takes precedence over the general non-empty-token rule other
/// single-token cards enforce (m2, contrast `parseNCard`). Single-occurrence;
/// required on a wiki artifact (§5.2/App. C) -- deferred to manifest
/// assembly.
pub const UCard = struct { login: []const u8 };
/// Parse one already-tokenized U card (§4.7.17). Returns null only for a
/// second token -- the grammar has room for exactly one, possibly-empty,
/// possibly-absent login.
/// Precondition: `decode_buf.len >= login token length (0 if absent/empty)`.
pub fn parseUCard(card: CardLine, decode_buf: []u8) ?UCard {
std.debug.assert(card.letter == 'U');
var it = card.tokens();
const login_tok = it.next(); // optional -- bare `U\n` has args == null
if (it.next() != null) return null; // r1: grammar has no second token
const login: []const u8 = if (login_tok) |tok| blk: {
if (tok.len == 0) break :blk ANONYMOUS_LOGIN; // m1: empty token
std.debug.assert(decode_buf.len >= tok.len);
break :blk decodeEscapes(tok, decode_buf[0..tok.len]);
} else ANONYMOUS_LOGIN; // m1: bare U line
std.debug.assert(login.len >= 1);
return .{ .login = login };
}
// ---------------------------------------------------------------------------
// §4.7.18 W Card
// ---------------------------------------------------------------------------
/// One parsed W card (§4.7.18 body card). `body` borrows the raw, opaque
/// payload -- located by byte count, MAY contain 0x0A, never line-split
/// (lines 1438-1440). `next` is the offset one past the body's
/// terminating LF, where the card loop resumes.
pub const WCard = struct { body: []const u8, next: usize };
/// Parse one W card starting at `artifact[start]` (the 'W' letter),
/// byte-counted rather than tokenized -- the body may embed LF and so
/// cannot flow through `tokenizeCardLine` (§4.7.18, l.422-433). Returns
/// null on any rejection: bad grammar, digit-accumulator overflow (r1), a
/// declared size exceeding the remaining artifact (r2), a non-LF byte
/// after the body (r3), or a terminator LF that is the artifact's final
/// byte (r4).
/// Precondition: `start < artifact.len` and `artifact[start] == 'W'` (the
/// card loop dispatches by letter).
/// Postcondition on success: `body.len` equals the parsed size and
/// `next < artifact.len`.
pub fn parseWCard(artifact: []const u8, start: usize) ?WCard {
std.debug.assert(start < artifact.len);
std.debug.assert(artifact[start] == 'W');
// w-card = %x57 SP size LF body LF (l.1429).
if (start + 1 >= artifact.len) return null;
if (artifact[start + 1] != ' ') return null;
// Locate the size-line terminator. Bounded by artifact.len.
var size_lf: usize = start + 2;
while (size_lf < artifact.len and artifact[size_lf] != '\n') : (size_lf += 1) {}
if (size_lf >= artifact.len) return null; // no LF before end
// p2/r1: accumulate leading decimal digits only, stop at the first
// non-digit byte, never re-check the remainder (w1: "12x" accepted;
// w2: no leading digit parses as zero). Overflow MUST reject (r1).
// Loop bounded by the size-line slice, itself bounded by artifact.len.
var size: usize = 0;
for (artifact[start + 2 .. size_lf]) |c| {
if (c < '0' or c > '9') break;
const mul = @mulWithOverflow(size, 10);
if (mul[1] != 0) return null;
const add = @addWithOverflow(mul[0], c - '0');
if (add[1] != 0) return null;
size = add[0];
}
const body_start = size_lf + 1;
std.debug.assert(body_start <= artifact.len);
// r2: bound the declared size against what remains before consuming.
const remaining = artifact.len - body_start;
if (size > remaining) return null;
const body_end = body_start + size;
std.debug.assert(body_end <= artifact.len);
// r4: the position one past the terminator LF must stay addressable.
if (body_end + 1 >= artifact.len) return null;
// r3: the byte right after the counted body must be LF. m1: the body
// itself is never scanned -- it is sliced by offset, not searched.
if (artifact[body_end] != '\n') return null;
const body = artifact[body_start..body_end];
const next = body_end + 1;
std.debug.assert(body.len == size);
std.debug.assert(next < artifact.len);
return .{ .body = body, .next = next };
}
// ---------------------------------------------------------------------------
// §4.7.19 Z Card
// ---------------------------------------------------------------------------
/// One parsed Z card (§4.7.19 self-checksum card). `checksum` is the raw
/// md5-token, never decoded (§6.1). The "structurally last, final 35
/// bytes" property (m1) is enforced by the §6.2 positional framing
/// (`verifyZCard`), not here -- this validates only token syntax.
pub const ZCard = struct { checksum: []const u8 };
/// Parse one already-tokenized Z card (§4.7.19). Returns null for bad
/// arity (r2: zero or a second token) or a token failing the 32-hex-ci
/// checksum rule (r1, §6.1).
pub fn parseZCard(card: CardLine) ?ZCard {
std.debug.assert(card.letter == 'Z');
var it = card.tokens();
const tok = it.next() orelse return null; // r2: token required
if (it.next() != null) return null; // r2: grammar has no second token
if (!validateHashToken(.checksum, tok)) return null; // r1: 32hexdig-ci
std.debug.assert(tok.len == 32);
return .{ .checksum = tok };
}
// ---------------------------------------------------------------------------
// §5 Structural Artifact Types
// ---------------------------------------------------------------------------
/// Card-presence census over one artifact's cards. Built by the (later)
/// single-pass parser; classification and validity below consume this
/// summary instead of re-scanning raw card bytes.
pub const Census = struct {
/// Bit (letter - 'A') set iff at least one card of that letter was
/// seen in the artifact.
letters: u32 = 0,
/// Count of hash tokens recorded across all P cards. Only "zero / one
/// / more than one" is ever distinguished downstream (§5.4-r5), so a
/// future producer may saturate rather than track the exact count.
p_parents: u8 = 0,
/// At least one T card targets the literal `*` -- self-referential
/// (§4.7.16 p2; consumed by §5.4 checks 1 and 2).
t_self_ref: bool = false,
/// At least one T card targets a hash, not `*` (§5.4 check 2).
t_not_self_ref: bool = false,
/// At least one T card's sign is not `+` (§4.7.16 tag-sign; §5.4
/// check 2).
t_not_plus: bool = false,
/// Whether a card of `letter` was recorded in this census.
pub fn has(self: Census, letter: u8) bool {
std.debug.assert(letter >= 'A' and letter <= 'Z');
return (self.letters & (@as(u32, 1) << @intCast(letter - 'A'))) != 0;
}
};
/// The thirteen type-setting letters (§5.3 Table 5), ascending. Card
/// order on the wire is non-decreasing (§4.4), so folding this array in
/// ascending order and unconditionally overwriting on each match
/// reproduces "the alphabetically latest present type-setting card wins"
/// (r1) without needing the actual card sequence.
const type_setting_letters = [_]u8{ 'A', 'B', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'Q', 'R' };
/// §5.3 Table 5: the type one type-setting letter assigns.
fn typeSettingType(letter: u8) ArtifactType {
return switch (letter) {
'A' => .attachment,
'B', 'F', 'Q', 'R' => .manifest,
'E' => .event,
'G', 'H', 'I' => .forum,
'J', 'K' => .ticket,
'L' => .wiki,
'M' => .cluster,
else => unreachable, // caller only passes type_setting_letters
};
}
/// §5.3: determine an artifact's type from its card census. The eight
/// remaining letters (C, D, N, P, T, U, W, Z) never set the type (m1); if
/// no type-setting letter is present, fall back per p2. Total: always
/// returns a concrete code-1..8 type, never the code-0 "undetermined"
/// sentinel (§5.1-m1) -- that sentinel is modelled only as the absence of
/// an input census entry, never as this function's output.
pub fn determineType(c: Census) ArtifactType {
var result: ?ArtifactType = null;
// Bounded: exactly type_setting_letters.len iterations.
for (type_setting_letters) |letter| {
if (c.has(letter)) result = typeSettingType(letter);
}
const t = result orelse fallback(c);
std.debug.assert(@intFromEnum(t) >= 1 and @intFromEnum(t) <= 8);
return t;
}
/// §5.3 p2 fallback, reached only when no type-setting letter was seen:
/// MANIFEST if a C card was present or P-card parents were recorded,
/// CONTROL otherwise. The RFC's third fallback disjunct ("F card file
/// entries recorded") is unreachable through this path -- F is itself
/// type-setting, so an artifact carrying F entries already resolved to
/// MANIFEST in the loop above and never reaches fallback.
fn fallback(c: Census) ArtifactType {
// Precondition (negative space): no type-setting letter present.
for (type_setting_letters) |letter| std.debug.assert(!c.has(letter));
return if (c.has('C') or c.p_parents >= 1) .manifest else .control;
}
/// Comptime helper: OR together the per-letter bits of a Table 4 letter
/// list into one mask.
fn maskOfLetters(comptime letters: []const u8) u32 {
var m: u32 = 0;
for (letters) |l| m |= @as(u32, 1) << @intCast(l - 'A');
return m;
}
/// §5.2 Table 4 "Allowed cards" column, one bit per letter per type.
const allowed_mask = std.EnumArray(ArtifactType, u32).init(.{
.manifest = maskOfLetters("BCDFNPQRTUZ"),
.cluster = maskOfLetters("MZ"),
.control = maskOfLetters("DTUZ"),
.wiki = maskOfLetters("CDLNPUWZ"),
.ticket = maskOfLetters("DJKUZ"),
.attachment = maskOfLetters("ACDNUZ"),
.event = maskOfLetters("CDENPTUWZ"),
.forum = maskOfLetters("DGHINPUWZ"),
});
/// §5.2 Table 4 "Required cards" column, one bit per letter per type.
const required_mask = std.EnumArray(ArtifactType, u32).init(.{
.manifest = maskOfLetters("DZ"),
.cluster = maskOfLetters("MZ"),
.control = maskOfLetters("DTUZ"),
.wiki = maskOfLetters("DLUWZ"),
.ticket = maskOfLetters("DJKUZ"),
.attachment = maskOfLetters("ADZ"),
.event = maskOfLetters("DEWZ"),
.forum = maskOfLetters("DUWZ"),
});
comptime {
// Table 4 invariant: every required card is also allowed, for all
// eight types -- a transcription guard against the two columns
// silently disagreeing.
for (std.enums.values(ArtifactType)) |t| {
std.debug.assert(required_mask.get(t) & allowed_mask.get(t) == required_mask.get(t));
}
}
pub const ClassifyError = error{
/// §5.2-r1: a card letter outside the finalized type's allowed set.
DisallowedCard,
/// §5.2-r2: a required card letter is absent.
MissingRequiredCard,
/// §5.4 check 1: CONTROL with a self-referential T card.
ControlSelfTag,
/// §5.4 check 2: EVENT with a T card that does not target itself.
EventTagNotSelfRef,
/// §5.4 check 2: EVENT with a T card lacking the `+` prefix.
EventTagNotPlus,
/// §5.4 check 3: FORUM with both an H and an I card.
ForumHeadAndReply,
/// §5.4 check 4: FORUM whose P card lists more than one parent.
ForumMultiParent,
};
/// §5.2: reject any card letter outside `t`'s allowed set, or any missing
/// required letter.
fn checkLegality(t: ArtifactType, c: Census) ClassifyError!void {
const allowed = allowed_mask.get(t);
const required = required_mask.get(t);
if (c.letters & ~allowed != 0) return error.DisallowedCard; // r1
if (c.letters & required != required) return error.MissingRequiredCard; // r2
}
/// §5.4: the four per-type semantic checks beyond card legality.
fn checkSemantics(t: ArtifactType, c: Census) ClassifyError!void {
switch (t) {
.control => {
if (c.t_self_ref) return error.ControlSelfTag; // check 1
},
.event => {
if (c.t_not_self_ref) return error.EventTagNotSelfRef; // check 2
if (c.t_not_plus) return error.EventTagNotPlus; // check 2
},
.forum => {
if (c.has('H') and c.has('I')) return error.ForumHeadAndReply; // check 3
if (c.p_parents > 1) return error.ForumMultiParent; // check 4
},
else => {},
}
}
/// §5 deep entry point: finalize the type (§5.3), then card legality
/// (§5.2), then per-type semantics (§5.4) -- strictly in that order, per
/// §5.2-m1 and §5.3-m2.
pub fn classify(c: Census) ClassifyError!ArtifactType {
const t = determineType(c);
try checkLegality(t, c);
try checkSemantics(t, c);
std.debug.assert(@intFromEnum(t) >= 1 and @intFromEnum(t) <= 8);
return t;
}
// ---------------------------------------------------------------------------
// §4/§5 Manifest Assembly
// ---------------------------------------------------------------------------
/// A fully-decoded artifact (the Deck shape, per the operator-approved
/// fsl_deck/go-libfossil design): one field per card type, holding every
/// card's decoded data, not just the type verdict. `arena_state` OWNS every
/// slice below except the raw wire-borrows (hashes/timestamps/W body),
/// which point into the caller's `bytes` -- the caller must keep `bytes`
/// alive for the Manifest's lifetime and must call `deinit` exactly once.
pub const Manifest = struct {
arena_state: std.heap.ArenaAllocator,
// §5.1-m1: a successful parse always finalizes a code-1..8 type --
// "not yet determined" never survives to here, it is modelled as
// `parse` returning an error, not as a null field on this struct.
kind: ArtifactType,
// §4.5.1 single-occurrence cards: null iff the letter was absent.
a: ?ACard,
b: ?BCard,
c: ?CCard,
d: ?DCard,
e: ?ECard,
g: ?GCard,
h: ?HCard,
i: ?ICard,
k: ?KCard,
l: ?LCard,
n: ?NCard,
r: ?RCard,
u: ?UCard,
z: ?ZCard,
/// W's body only -- `WCard.next` is a parse-loop cursor, not artifact
/// data, so it never belongs on the retained Manifest.
w: ?[]const u8,
// §4.5.2 repeatable cards: arena slice in wire order; empty iff absent.
f: []const FCard,
j: []const JCard,
m: []const MCard,
p: []const PCard,
q: []const QCard,
t: []const TCard,
/// Frees every decoded slice this Manifest owns. Raw fields borrow the
/// caller's input bytes, unaffected -- the caller owns those.
pub fn deinit(self: *Manifest) void {
self.arena_state.deinit();
}
};
/// Growing accumulator threaded alongside `RunState` through the card loop,
/// one lane per card type, mirroring `Manifest`'s shape. Kept separate from
/// `RunState` -- this refactor adds a retention lane, it does not rewire
/// the §4.4/§4.5 gate `RunState` already enforces. All storage lives on the
/// caller's arena.
const ManifestBuilder = struct {
a: ?ACard = null,
b: ?BCard = null,
c: ?CCard = null,
d: ?DCard = null,
e: ?ECard = null,
g: ?GCard = null,
h: ?HCard = null,
i: ?ICard = null,
k: ?KCard = null,
l: ?LCard = null,
n: ?NCard = null,
r: ?RCard = null,
u: ?UCard = null,
z: ?ZCard = null,
w: ?[]const u8 = null,
f: std.ArrayList(FCard) = .empty,
j: std.ArrayList(JCard) = .empty,
m: std.ArrayList(MCard) = .empty,
p: std.ArrayList(PCard) = .empty,
q: std.ArrayList(QCard) = .empty,
t: std.ArrayList(TCard) = .empty,
/// Finalize the six repeatable lists into arena slices and hand back a
/// Manifest that owns `arena_state` (moved in by value -- safe: every
/// decoded slice above points into a heap buffer node the arena holds,
/// never into this struct itself). `kind` is `classify`'s output; the
/// retention<->validation invariant against `RunState.census` is the
/// caller's job (`parse`), since `census` lives there, not here.
fn finish(self: *ManifestBuilder, arena_state: *std.heap.ArenaAllocator, kind: ArtifactType) std.mem.Allocator.Error!Manifest {
const arena = arena_state.allocator();
// Finalize every owned slice BEFORE copying the arena by value. A
// toOwnedSlice can append a node to the arena's used_list, and Zig
// evaluates struct-literal fields in source order; copying arena_state
// ahead of these would capture a stale node list and leak whatever a
// later slice allocates. Locals force all arena growth to happen first.
const f = try self.f.toOwnedSlice(arena);
const j = try self.j.toOwnedSlice(arena);
const m = try self.m.toOwnedSlice(arena);
const p = try self.p.toOwnedSlice(arena);
const q = try self.q.toOwnedSlice(arena);
const t = try self.t.toOwnedSlice(arena);
return .{
.arena_state = arena_state.*,
.kind = kind,
.a = self.a,
.b = self.b,
.c = self.c,
.d = self.d,
.e = self.e,
.g = self.g,
.h = self.h,
.i = self.i,
.k = self.k,
.l = self.l,
.n = self.n,
.r = self.r,
.u = self.u,
.z = self.z,
.w = self.w,
.f = f,
.j = j,
.m = m,
.p = p,
.q = q,
.t = t,
};
}
};
/// §4's per-card/per-run rejections all collapse to `SyntaxError`, matching
/// the module's standing "every rejection reason collapses to null/false"
/// posture; §5's `classify` rejections keep their distinct variants, giving
/// per-clause coverage for §5.2/§5.4 without inventing a parallel taxonomy.
/// `OutOfMemory` surfaces the arena's allocation failures (§4.3 decode
/// scratch) -- closed set, no `anyerror` across this boundary.
pub const ParseError = error{ SyntaxError, OutOfMemory } || ClassifyError;
/// Mutable state threaded through one card loop run: the §4.4 order
/// tracker, §4.5.1 occurrence guard, §4.5.2/§4.6.3 F-run admission, the
/// two remaining §4.5.2 intra-run anchors (J, M single-key; T two-level),
/// and the growing §5 census the loop folds every card into.
const RunState = struct {
order: OrderState = .{},
occ: SingleOccurrenceSet = .{},
fadmit: FCardAdmission = .{},
prev_j: ?[]const u8 = null,
prev_m: ?[]const u8 = null,
prev_t: ?struct { name: []const u8, target: []const u8 } = null,
census: Census = .{},
};
/// Mark `letter` present in the running census (§5 card-presence bit).
fn censusMark(rs: *RunState, letter: u8) void {
std.debug.assert(isCardLetter(letter));
rs.census.letters |= @as(u32, 1) << @intCast(letter - 'A');
}
/// §4.5.2 single-key ascending check shared by the F/J/M runs' J and M
/// halves (F has its own admission type, `FCardAdmission`, since it also
/// carries the §4.6.3 reserved-name drop). Rejects iff `key` does not
/// strictly exceed the previous key in the same run.
fn admitAscending(prev: *?[]const u8, key: []const u8) ParseError!void {
if (prev.*) |p| {
if (compareBytes(p, key) != .lt) return error.SyntaxError;
}
prev.* = key;
}
/// True for the nine card letters whose §4.7.x parser decodes at least one
/// field via `decodeEscapes` -- these need a scratch buffer; the other
/// twelve tokenized letters carry only raw (hash/checksum/timestamp/sign)
/// positions and need none (§4.3.1).
fn letterNeedsDecodeBuf(letter: u8) bool {
return switch (letter) {
'A', 'C', 'F', 'H', 'J', 'L', 'N', 'T', 'U' => true,
else => false,
};
}
/// §4.5.2 T two-level ascending check plus the §5.4 census flags a T card
/// contributes (self-ref vs. targeted, sign != '+'). `target` folds the
/// self-ref sentinel to the literal wire byte `*` (ledger §4.5.2 reading
/// #1) so the sort key is always a concrete byte string.
fn admitTCard(arena: std.mem.Allocator, card: CardLine, decode_buf: []u8, rs: *RunState, mb: *ManifestBuilder) ParseError!void {
const tc = parseTCard(card, decode_buf) orelse return error.SyntaxError;
const target = tc.target orelse "*";
if (rs.prev_t) |prev| {
if (compareTag(prev.name, prev.target, tc.name, target) != .lt) return error.SyntaxError;
}
rs.prev_t = .{ .name = tc.name, .target = target };
if (tc.target == null) rs.census.t_self_ref = true else rs.census.t_not_self_ref = true;
if (tc.sign != .add) rs.census.t_not_plus = true;
try mb.t.append(arena, tc);
}
/// Dispatch one already-tokenized non-W card to its §4.7.x parser, run the
/// §4.5.2 intra-run check where the letter has one, and mark it present in
/// `rs.census`. `decode_buf` is a fresh arena slice sized to the card's raw
/// arg bytes -- always sufficient, since no parser's decoded fields exceed
/// the raw bytes they are decoded from.
fn admitTokenized(arena: std.mem.Allocator, card: CardLine, decode_buf: []u8, rs: *RunState, mb: *ManifestBuilder) ParseError!void {
switch (card.letter) {
'A' => mb.a = parseACard(card, decode_buf) orelse return error.SyntaxError,
'B' => mb.b = parseBCard(card) orelse return error.SyntaxError,
'C' => mb.c = parseCCard(card, decode_buf) orelse return error.SyntaxError,
'D' => mb.d = parseDCard(card) orelse return error.SyntaxError,
'E' => mb.e = parseECard(card) orelse return error.SyntaxError,
'F' => {
const fc = parseFCard(card, decode_buf) orelse return error.SyntaxError;
_ = rs.fadmit.admit(fc.filename) catch return error.SyntaxError; // §4.5.2/§4.6.3
try mb.f.append(arena, fc); // retained regardless of admit's drop verdict
},
'G' => mb.g = parseGCard(card) orelse return error.SyntaxError,
'H' => mb.h = parseHCard(card, decode_buf) orelse return error.SyntaxError,
'I' => mb.i = parseICard(card) orelse return error.SyntaxError,
'J' => {
const jc = parseJCard(card, decode_buf) orelse return error.SyntaxError;
try admitAscending(&rs.prev_j, jc.field);
try mb.j.append(arena, jc);
},
'K' => mb.k = parseKCard(card) orelse return error.SyntaxError,
'L' => mb.l = parseLCard(card, decode_buf) orelse return error.SyntaxError,
'M' => {
const mc = parseMCard(card) orelse return error.SyntaxError;
try admitAscending(&rs.prev_m, mc.hash);
try mb.m.append(arena, mc);
},
'N' => mb.n = parseNCard(card, decode_buf) orelse return error.SyntaxError,
'P' => {
const pc = parsePCard(card) orelse return error.SyntaxError;
const sum = @as(usize, rs.census.p_parents) + pc.count;
rs.census.p_parents = @intCast(@min(sum, 2)); // §5.4-r5: only 0/1/>1 matter
try mb.p.append(arena, pc);
},
'Q' => {
const qc = parseQCard(card) orelse return error.SyntaxError;
try mb.q.append(arena, qc);
},
'R' => mb.r = parseRCard(card) orelse return error.SyntaxError,
'T' => try admitTCard(arena, card, decode_buf, rs, mb),
'U' => mb.u = parseUCard(card, decode_buf) orelse return error.SyntaxError,
'Z' => mb.z = parseZCard(card) orelse return error.SyntaxError,
else => unreachable, // caller excludes 'W'; tokenizeCardLine admits only the 21 letters
}
censusMark(rs, card.letter);
}
/// Parse and admit exactly one card at `content[pos]`: frame the card
/// region (byte-count for W, LF-scan + tokenize otherwise), apply the
/// §4.4 order gate and §4.5.1 occurrence gate, then (non-W) dispatch to
/// the §4.7.x grammar and §4.5.2 intra-run check. Returns the position
/// immediately after the card. Bound: the shortest legal card is `X\n` (2
/// bytes), so the return value always exceeds `pos`.
fn admitCard(arena: std.mem.Allocator, content: []const u8, pos: usize, rs: *RunState, mb: *ManifestBuilder) ParseError!usize {
std.debug.assert(pos < content.len);
const letter = content[pos];
if (letter == 'W') {
const w = parseWCard(content, pos) orelse return error.SyntaxError;
if (!rs.order.admit(letter)) return error.SyntaxError; // §4.4
if (!rs.occ.admit(letter)) return error.SyntaxError; // §4.5.1
censusMark(rs, letter);
mb.w = w.body;
std.debug.assert(w.next > pos);
return w.next;
}
const lf = std.mem.indexOfScalarPos(u8, content, pos, '\n') orelse return error.SyntaxError;
const card = tokenizeCardLine(content[pos .. lf + 1]) orelse return error.SyntaxError;
if (!rs.order.admit(letter)) return error.SyntaxError; // §4.4
if (!rs.occ.admit(letter)) return error.SyntaxError; // §4.5.1
var decode_buf: []u8 = &.{};
if (letterNeedsDecodeBuf(letter)) {
const arg_len = if (card.args) |a| a.len else 0;
decode_buf = try arena.alloc(u8, arg_len); // may propagate OutOfMemory
}
try admitTokenized(arena, card, decode_buf, rs, mb);
std.debug.assert(lf + 1 > pos);
return lf + 1;
}
/// Parse a structural artifact from its bytes: §4 framing and card grammar,
/// then §5 classification. `bytes` is the raw (possibly signature-wrapped)
/// artifact; the full pipeline is `frame` -> §5.5 pre-filter -> card loop ->
/// `classify`. Returns a fully-populated, data-carrying `Manifest` (every
/// decoded card retained, not just the type verdict) or a `ParseError` --
/// never a partial result. The returned Manifest owns an arena; the caller
/// must call `manifest.deinit()` exactly once and must keep `bytes` alive
/// for the Manifest's lifetime (raw fields borrow it, zero-copy).
pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) ParseError!Manifest {
const content = frame(bytes) orelse return error.SyntaxError; // §4.1/§6.2
std.debug.assert(content.len >= 10); // frame's gate-3 postcondition
// §5.5 well-formedness pre-filter: every type's smallest required
// letter is <= 'M' (CLUSTER's own required M), and card order is
// non-decreasing, so the artifact's first letter can never exceed the
// smallest required letter of a type it could possibly satisfy. A
// first letter above 'M' fails the same way the full parse eventually
// would (§4.1.2) -- this only fast-paths, it never diverges on
// accept/reject.
if (content[0] > 'M') return error.SyntaxError;
std.debug.assert(content[0] >= 'A' and content[0] <= 'M');
// §4.4 ownership flip: the arena is freed on the error path only. On
// success it is moved by value into the returned Manifest (via
// `mb.finish`) -- the caller owns it from there and calls `deinit`.
var arena_state = std.heap.ArenaAllocator.init(allocator);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
var rs: RunState = .{};
var mb: ManifestBuilder = .{};
var pos: usize = 0;
const LoopState = enum { parse_card, done };
state: switch (LoopState.parse_card) {
.parse_card => {
if (pos >= content.len) continue :state .done;
const next = try admitCard(arena, content, pos, &rs, &mb);
std.debug.assert(next > pos); // strict progress bound
pos = next;
continue :state .parse_card;
},
.done => {},
}
std.debug.assert(pos == content.len); // §4.1: unconsumed bytes never happen here
const kind = try classify(rs.census); // §5.3 -> §5.2 -> §5.4
const manifest = try mb.finish(&arena_state, kind);
assertRetentionMatchesCensus(manifest, rs.census, content.len);
return manifest;
}
/// Retention<->validation invariant (positive + negative space): every
/// retained field's presence must exactly match the census bit the
/// (untouched) validation lane already recorded, and every repeatable
/// list's length stays within the card loop's own progress bound (`next >
/// pos`, so the card count -- and each list's length -- can never exceed
/// `content_len`). Catches any retain/verdict divergence this batch's
/// refactor could introduce; never mutates.
fn assertRetentionMatchesCensus(m: Manifest, census: Census, content_len: usize) void {
std.debug.assert((m.a != null) == census.has('A'));
std.debug.assert((m.b != null) == census.has('B'));
std.debug.assert((m.c != null) == census.has('C'));
std.debug.assert((m.d != null) == census.has('D'));
std.debug.assert((m.e != null) == census.has('E'));
std.debug.assert((m.g != null) == census.has('G'));
std.debug.assert((m.h != null) == census.has('H'));
std.debug.assert((m.i != null) == census.has('I'));
std.debug.assert((m.k != null) == census.has('K'));
std.debug.assert((m.l != null) == census.has('L'));
std.debug.assert((m.n != null) == census.has('N'));
std.debug.assert((m.r != null) == census.has('R'));
std.debug.assert((m.u != null) == census.has('U'));
std.debug.assert((m.w != null) == census.has('W'));
std.debug.assert((m.z != null) == census.has('Z'));
std.debug.assert((m.f.len > 0) == census.has('F'));
std.debug.assert((m.j.len > 0) == census.has('J'));
std.debug.assert((m.m.len > 0) == census.has('M'));
std.debug.assert((m.p.len > 0) == census.has('P'));
std.debug.assert((m.q.len > 0) == census.has('Q'));
std.debug.assert((m.t.len > 0) == census.has('T'));
std.debug.assert(m.f.len <= content_len);
std.debug.assert(m.j.len <= content_len);
std.debug.assert(m.m.len <= content_len);
std.debug.assert(m.p.len <= content_len);
std.debug.assert(m.q.len <= content_len);
std.debug.assert(m.t.len <= content_len);
}
// ---------------------------------------------------------------------------
// §8 Delta Manifest Reconstruction
// ---------------------------------------------------------------------------
/// The minimal view §8 reconstruction reads from a manifest: its B-card hash
/// (null iff baseline manifest, §8-p1) and its own F cards, sorted by
/// decoded filename (§8.1 parse-time invariant -- every successfully parsed
/// manifest satisfies it). Manifest assembly (a later batch) will produce
/// this view; §8 depends on nothing else about the manifest.
pub const ManifestFiles = struct {
baseline_hash: ?[]const u8,
files: []const FCard,
};
/// §8-p1: "A manifest is a delta manifest if and only if it contains a B
/// card; otherwise it is a baseline manifest."
pub fn isDelta(m: ManifestFiles) bool {
return m.baseline_hash != null;
}
/// Injected seam resolving a B-card hash to the baseline artifact, viewed
/// as `ManifestFiles` (§8.3). No store or IO exists at this layer -- the
/// caller supplies resolution. `null` means the baseline could not be
/// located, or the located artifact is not a manifest at all; both collapse
/// to `BaselineUnresolvable` (§8.3-m1), the caller cannot tell them apart
/// and doesn't need to. A resolved *delta* manifest MUST be returned as-is
/// (its `baseline_hash` left non-null, never collapsed to null) so
/// `enumerate`/`lookup` can reject the delta-of-delta case at resolution
/// time (§4.7.3). The returned slice must outlive the walk -- caller-owned.
pub const BaselineResolver = struct {
ctx: *anyopaque,
resolveFn: *const fn (ctx: *anyopaque, hash: []const u8) ?ManifestFiles,
fn resolve(self: BaselineResolver, hash: []const u8) ?ManifestFiles {
return self.resolveFn(self.ctx, hash);
}
};
/// §8.3 hard-failure surface for both enumeration entry points (full walk,
/// §8.2/§8.3, and single-name lookup, §8.4). `BaselineUnresolvable`
/// collapses "not located" and "not a manifest" into one fatal outcome
/// (§8.3-m1). `BaselineIsDelta` stays distinct: it fires only after a
/// *successful* resolve, when the resolved baseline itself carries a B
/// card -- the delta-of-delta rejection (§4.7.3).
pub const BaselineError = error{ BaselineUnresolvable, BaselineIsDelta };
/// §8.1 precondition: `files` strictly ascending by filename under the
/// canonical comparator (§4.5.3). A parse-time invariant of every
/// successfully parsed manifest -- asserted here, not re-validated.
fn assertSorted(files: []const FCard) void {
if (files.len > 1) {
for (1..files.len) |i| {
std.debug.assert(compareBytes(files[i - 1].filename, files[i].filename) == .lt);
}
}
}
/// §8.1-r1 / §4.7.6 negative-space companion: a baseline manifest's F cards
/// never carry a delete marker (every hash is present). Sound only where
/// `files` is known to come from a genuine baseline -- `enumerate`/`lookup`
/// reject a resolved delta baseline before this ever runs on it.
fn assertNoDeleteMarkers(files: []const FCard) void {
for (files) |f| std.debug.assert(f.hash != null);
}
/// §8.2 engine: allocation-free filename-ordered merge-walk over a
/// baseline's and a delta's F-card lists (both pre-sorted per §8.1-p1).
/// `next()` yields each emitted card verbatim, including an unmatched
/// delete marker (§8.2-w1) and any tail delete markers (§8.2-m1); it
/// suppresses only the matched-delete case (step 2, §8.2-r2).
pub const MergeWalk = struct {
baseline: []const FCard,
delta: []const FCard,
bi: usize = 0,
di: usize = 0,
/// §8.2 step-by-step merge. Every pass through the loop either returns
/// (an entry was emitted) or advances both cursors (step 2's matched
/// delete, which emits nothing), so the loop makes monotonic progress;
/// the step counter plus assert catches a broken invariant rather than
/// looping past the bound (§8.2-m1: terminates only once both cursors
/// are exhausted).
pub fn next(self: *MergeWalk) ?FCard {
std.debug.assert(self.bi <= self.baseline.len);
std.debug.assert(self.di <= self.delta.len);
const bound = self.baseline.len + self.delta.len + 1;
var steps: usize = 0;
while (true) {
std.debug.assert(steps <= bound);
steps += 1;
const baseline_done = self.bi >= self.baseline.len;
const delta_done = self.di >= self.delta.len;
if (baseline_done and delta_done) return null; // m1: both exhausted
if (baseline_done) { // delta tail verbatim, incl. markers (m1)
const f = self.delta[self.di];
self.di += 1;
return f;
}
if (delta_done) { // baseline tail verbatim (m1)
const f = self.baseline[self.bi];
self.bi += 1;
return f;
}
const order = compareBytes(self.baseline[self.bi].filename, self.delta[self.di].filename);
switch (order) {
.lt => { // baseline sorts first (step 3)
const f = self.baseline[self.bi];
self.bi += 1;
return f;
},
.gt => { // delta sorts first, incl. unmatched marker (step 3, w1)
const f = self.delta[self.di];
self.di += 1;
return f;
},
.eq => {
const d = self.delta[self.di];
self.bi += 1;
self.di += 1;
if (d.hash != null) return d; // step 1: delta replaces baseline
// step 2: matched delete -- no entry emitted, loop again
},
}
}
}
};
/// §8.2 + §8.3 hard-failure entry point: resolve the baseline lazily -- only
/// for a delta manifest, only here (§8.3-p1) -- before any iteration begins
/// (§8.3-m2, no silent delta-only degrade), reject a delta baseline
/// (§4.7.3), then hand back the ready walk.
pub fn enumerate(m: ManifestFiles, resolver: BaselineResolver) BaselineError!MergeWalk {
assertSorted(m.files);
if (!isDelta(m)) {
assertNoDeleteMarkers(m.files); // §4.7.6: baseline F cards all carry a hash
return MergeWalk{ .baseline = m.files, .delta = &.{} };
}
const resolved = resolver.resolve(m.baseline_hash.?) orelse return error.BaselineUnresolvable; // §8.3-m1
if (isDelta(resolved)) return error.BaselineIsDelta; // §4.7.3: delta-of-delta, rejected at resolution
assertSorted(resolved.files);
assertNoDeleteMarkers(resolved.files);
return MergeWalk{ .baseline = resolved.files, .delta = m.files };
}
pub const LookupResult = union(enum) {
found: FCard,
not_found,
};
/// §8.4 exact-match scan of one already-sorted F-card list. Bounded by
/// `files.len`; terminates early once the sorted invariant proves no later
/// entry can match (best-match/closest-name lookup is not a zit feature --
/// this is the only search shape, for both delta and baseline, §8.4-m1).
fn scanExact(files: []const FCard, name: []const u8) ?FCard {
for (files) |f| {
const order = compareBytes(name, f.filename);
if (order == .eq) return f;
if (order == .lt) break; // sorted: nothing further can match
}
return null;
}
/// §8.4: resolve one filename against a manifest. The delta's own F cards
/// are searched first (m1). A delta delete marker for the name reports
/// not-found without consulting the baseline (m2). Only when the name is
/// entirely absent from the delta is the baseline resolved and searched
/// (p1), rejecting a resolved delta baseline (§4.7.3) the same as
/// `enumerate`.
pub fn lookup(m: ManifestFiles, name: []const u8, resolver: BaselineResolver) BaselineError!LookupResult {
assertSorted(m.files);
if (!isDelta(m)) assertNoDeleteMarkers(m.files); // §4.7.6: baseline carries no delete markers
if (scanExact(m.files, name)) |f| {
if (f.hash != null) return .{ .found = f };
return .not_found; // m2: delete marker, baseline never consulted
}
if (!isDelta(m)) return .not_found;
const resolved = resolver.resolve(m.baseline_hash.?) orelse return error.BaselineUnresolvable; // §8.3-m1
if (isDelta(resolved)) return error.BaselineIsDelta; // §4.7.3
assertSorted(resolved.files);
if (scanExact(resolved.files, name)) |f| {
std.debug.assert(f.hash != null); // §4.7.6: baseline carries no delete markers
return .{ .found = f };
}
return .not_found;
}
// ---------------------------------------------------------------------------
// §6/§4 Writer: Manifest -> Canonical Bytes
// ---------------------------------------------------------------------------
/// `create`'s sole failure mode -- a `Manifest` is valid by construction
/// (every field was already grammar-checked by `parse`), so the only way
/// serialization can fail is the output allocation itself. Closed set, no
/// `anyerror` across the boundary.
pub const WriteError = error{OutOfMemory};
/// Append escape-encoded `token` (§4.3, the writer-side inverse of
/// `decodeEscapes`). Grows `buf` by exactly the encoded length: reserve the
/// worst case (`2 * token.len`, §4.3's own bound), encode into that tail,
/// then shrink back to what was actually written.
fn appendEscaped(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, token: []const u8) WriteError!void {
const dst = try buf.addManyAsSlice(allocator, 2 * token.len);
const written = encodeEscapes(token, dst);
std.debug.assert(written.len <= dst.len);
buf.items.len -= (dst.len - written.len);
}
/// Append the minimal decimal ASCII rendering of `n` -- no leading zeros,
/// no sign (§4.7.18 W-card size prefix). Digit extraction mirrors
/// `updateSizeHeader`'s (§6.3.2); kept as its own small function rather than
/// shared, since that one's job is a fixed `SP digits LF` MD5-context
/// header, not a general buffer append.
fn appendMinimalDecimal(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, n: usize) WriteError!void {
var digits: [max_size_digits]u8 = undefined;
var n_digits: usize = 0;
var v = n;
while (true) {
std.debug.assert(n_digits < digits.len);
digits[n_digits] = @as(u8, @intCast(v % 10)) + '0';
n_digits += 1;
v /= 10;
if (v == 0) break;
}
std.debug.assert(n_digits >= 1 and n_digits <= digits.len);
try buf.ensureUnusedCapacity(allocator, n_digits);
var i: usize = n_digits;
while (i > 0) {
i -= 1;
buf.appendAssumeCapacity(digits[i]); // most-significant digit first
}
}
/// Append one single-token RAW card: `<letter> <token>\n`. `letter` must be
/// one of the twelve hash/checksum/timestamp/sign positions §4.3.1 never
/// escape-decodes, so the token is never escape-encoded on the way back out.
fn appendRawCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, letter: u8, token: []const u8) WriteError!void {
std.debug.assert(isCardLetter(letter));
try buf.append(allocator, letter);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, token);
try buf.append(allocator, '\n');
}
/// Append one single-token FREE-TEXT card: `<letter> <esc(token)>\n` (§4.3
/// writer inverse of `decodeEscapes`).
fn appendEscapedCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, letter: u8, token: []const u8) WriteError!void {
std.debug.assert(isCardLetter(letter));
try buf.append(allocator, letter);
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, token);
try buf.append(allocator, '\n');
}
/// §4.7.2 A card: `A <esc(name)> <esc(target)> [<source>]\n`. `source` is
/// raw (the parser's one exception among hash-bearing positions, §4.3.1
/// line 680); a prefixed filename never round-trips (parse keeps only the
/// basename, §4.7.2-m1) -- not reachable from any card this batch's
/// fixtures carry.
fn appendACard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, a: ACard) WriteError!void {
try buf.append(allocator, 'A');
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, a.name);
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, a.target);
if (a.source) |src| {
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, src);
}
try buf.append(allocator, '\n');
}
/// §4.7.5 E card: `E <timestamp> <technote-id>\n`, both raw.
fn appendECard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, e: ECard) WriteError!void {
try buf.append(allocator, 'E');
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, e.timestamp);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, e.technote_id);
try buf.append(allocator, '\n');
}
/// §4.7.6 F card: `F <esc(filename)> [<hash>] [<perm>] [<esc(old)>]\n`. The
/// three trailing positions nest strictly (perm only with hash, old_name
/// only with perm) -- a structural invariant of every successfully-parsed
/// FCard, since `parseFCard` reads them positionally, not by name.
fn appendFCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, f: FCard) WriteError!void {
try buf.append(allocator, 'F');
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, f.filename);
if (f.hash) |h| {
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, h);
}
if (f.perm) |p| {
std.debug.assert(f.hash != null);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, p);
}
if (f.old_name) |old| {
std.debug.assert(f.perm != null);
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, old);
}
try buf.append(allocator, '\n');
}
/// §4.7.8 J card: `J <field> [<esc(value)>]\n`. `field` is raw (never
/// decoded on read, §4.7.8); an empty `value` is the absent-value sentinel
/// (m3) and is omitted, never emitted as a trailing bare SP.
fn appendJCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, j: JCard) WriteError!void {
try buf.append(allocator, 'J');
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, j.field);
if (j.value.len > 0) {
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, j.value);
}
try buf.append(allocator, '\n');
}
/// §4.7.13 P card: `P [<parents>]\n`. `count == 0` (bare `P\n`, m1) is the
/// only case with no argument; `parents` otherwise carries the already
/// SP-joined raw hash tokens verbatim.
fn appendPCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, p: PCard) WriteError!void {
try buf.append(allocator, 'P');
if (p.count > 0) {
std.debug.assert(p.parents != null);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, p.parents.?);
}
try buf.append(allocator, '\n');
}
/// §4.7.14 Q card: `Q <sign><target> [<baseline>]\n`. Sign and target glue
/// into one wire token with no SP between them (q-sign is a byte prefix on
/// the hash, not a separate token).
fn appendQCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, q: QCard) WriteError!void {
try buf.append(allocator, 'Q');
try buf.append(allocator, ' ');
try buf.append(allocator, if (q.sign == .cherrypick) '+' else '-');
try buf.appendSlice(allocator, q.target);
if (q.baseline) |bl| {
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, bl);
}
try buf.append(allocator, '\n');
}
/// §6.3.4 R card: `R <checksum>\n`, lowered -- the parser accepts either
/// case (§6.1), but the producer MUST always render exactly 32 lowercase
/// hex digits (§6.3.4).
fn appendRCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, r: RCard) WriteError!void {
std.debug.assert(r.checksum.len == 32);
var lower: [32]u8 = undefined;
for (r.checksum, 0..) |c, i| lower[i] = std.ascii.toLower(c);
try appendRawCard(buf, allocator, 'R', &lower);
}
/// §4.7.16 T card: `T <sign><esc(name)> <target|*> [<esc(value)>]\n`. Sign
/// and name glue into one token (no SP), matching Q's sign convention; a
/// null `target` is the literal self-referential wire byte `*` (m3).
fn appendTCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, t: TCard) WriteError!void {
const sign_byte: u8 = switch (t.sign) {
.add => '+',
.cancel => '-',
.propagate => '*',
};
try buf.append(allocator, 'T');
try buf.append(allocator, ' ');
try buf.append(allocator, sign_byte);
try appendEscaped(buf, allocator, t.name);
try buf.append(allocator, ' ');
if (t.target) |tgt| {
try buf.appendSlice(allocator, tgt);
} else {
try buf.append(allocator, '*');
}
if (t.value) |v| {
try buf.append(allocator, ' ');
try appendEscaped(buf, allocator, v);
}
try buf.append(allocator, '\n');
}
/// §4.7.18 W card: `W <size>\n<body>\n`, size-counted rather than tokenized
/// (the body may itself embed LF, §4.7.18).
fn appendWCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, body: []const u8) WriteError!void {
try buf.append(allocator, 'W');
try buf.append(allocator, ' ');
try appendMinimalDecimal(buf, allocator, body.len);
try buf.append(allocator, '\n');
try buf.appendSlice(allocator, body);
try buf.append(allocator, '\n');
}
/// Serialize `manifest` to canonical artifact bytes (§4/§6). Cards are
/// emitted in the fixed §4.7/§4.4 lexical order A,B,C,D,E,F,G,H,I,J,K,L,M,
/// N,P,Q,R,T,U,W,Z -- the writer never reasons about the reader's N-after-P
/// compat exception (§4.4), since walking the Manifest's own fields in this
/// order always produces a strictly non-decreasing sequence. Z is computed
/// over the serialized prefix and appended last (§6.2); `manifest.z` (the
/// parsed Z, if any) is never read. Caller owns the returned slice and
/// frees it with `allocator`.
pub fn create(manifest: *const Manifest, allocator: std.mem.Allocator) WriteError![]u8 {
std.debug.assert(@intFromEnum(manifest.kind) >= 1 and @intFromEnum(manifest.kind) <= 8);
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(allocator);
if (manifest.a) |a| try appendACard(&buf, allocator, a);
if (manifest.b) |b| try appendRawCard(&buf, allocator, 'B', b.hash);
if (manifest.c) |c| try appendEscapedCard(&buf, allocator, 'C', c.comment);
if (manifest.d) |d| try appendRawCard(&buf, allocator, 'D', d.timestamp);
if (manifest.e) |e| try appendECard(&buf, allocator, e);
for (manifest.f) |f| try appendFCard(&buf, allocator, f);
if (manifest.g) |g| try appendRawCard(&buf, allocator, 'G', g.hash);
if (manifest.h) |h| try appendEscapedCard(&buf, allocator, 'H', h.title);
if (manifest.i) |i| try appendRawCard(&buf, allocator, 'I', i.hash);
for (manifest.j) |j| try appendJCard(&buf, allocator, j);
if (manifest.k) |k| try appendRawCard(&buf, allocator, 'K', k.ticket_id);
if (manifest.l) |l| try appendEscapedCard(&buf, allocator, 'L', l.title);
for (manifest.m) |m| try appendRawCard(&buf, allocator, 'M', m.hash);
if (manifest.n) |n| try appendEscapedCard(&buf, allocator, 'N', n.mimetype);
for (manifest.p) |p| try appendPCard(&buf, allocator, p);
for (manifest.q) |q| try appendQCard(&buf, allocator, q);
if (manifest.r) |r| try appendRCard(&buf, allocator, r);
for (manifest.t) |t| try appendTCard(&buf, allocator, t);
if (manifest.u) |u| try appendEscapedCard(&buf, allocator, 'U', u.login);
if (manifest.w) |w| try appendWCard(&buf, allocator, w);
var digest: [32]u8 = undefined;
_ = selfChecksum(buf.items, &digest); // §6.2: over everything emitted so far
try buf.append(allocator, 'Z');
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, &digest);
try buf.append(allocator, '\n');
const out = try buf.toOwnedSlice(allocator);
std.debug.assert(out.len >= 35); // §4.1: minimum framing (the Z card alone)
std.debug.assert(out[out.len - 1] == '\n');
std.debug.assert(out[out.len - 35] == 'Z' and out[out.len - 34] == ' ');
std.debug.assert(verifyZCard(out) == .match); // the writer's own output MUST self-verify
return out;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// Residual unit tests that exercise file-private decls (kept inline;
// making those decls pub merely to test them would leak the interface).
const a1_1_manifest = @embedFile("rfc_vectors/artifact_a1_1_sha1_manifest.txt");
const A_CARD_HASH40: []const u8 = "0123456789" ** 4; // 40 hex chars
const TEST_TS = "2007-07-21T14:09:59"; // §4.6.4 writer form, reused across bodies
const HASH40_HIGH: []const u8 = "f" ** 40; // sorts above A_CARD_HASH40 (§4.5.3)
fn buildArtifact(buf: []u8, body: []const u8) []const u8 {
std.debug.assert(buf.len >= body.len + 35);
@memcpy(buf[0..body.len], body);
var digest_buf: [32]u8 = undefined;
const digest = selfChecksum(body, &digest_buf);
buf[body.len] = 'Z';
buf[body.len + 1] = ' ';
@memcpy(buf[body.len + 2 ..][0..32], digest);
buf[body.len + 34] = '\n';
const total = body.len + 35;
std.debug.assert(buf[total - 1] == '\n');
return buf[0..total];
}
test "artifact §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 §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 §6.3.4-r1: uppercase R fails the byte-exact commit check" {
const files = [_]TrackedFile{
.{ .name = "alpha.txt", .content = "hello world\n" },
.{ .name = "beta.txt", .content = "fossil artifact format\n" },
.{ .name = "gamma.txt", .content = "R card test\n" },
};
var out: [32]u8 = undefined;
const recomputed = aggregateChecksum(&files, &out);
for (recomputed) |c| try std.testing.expect(isHexLower(c));
var uppercased: [32]u8 = undefined;
for (recomputed, 0..) |c, i| uppercased[i] = std.ascii.toUpper(c);
// Byte-exact compare against an always-lowercase recomputed digest --
// an uppercase R card fails this even though it is numerically correct.
try std.testing.expect(!std.mem.eql(u8, &uppercased, recomputed));
}
test "artifact §5.1: undetermined type is always resolved (sentinel never final)" {
// Code 0 is never implemented as a value (m1); a census with no
// type-setting card still resolves to a concrete type via fallback.
try std.testing.expectEqual(ArtifactType.control, determineType(.{ .letters = maskOfLetters("DZ") }));
try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("CDZ") }));
}
test "artifact §5.2: allowed and required sets match Table 4" {
const Row = struct { t: ArtifactType, allowed: []const u8, required: []const u8 };
const rows = [_]Row{
.{ .t = .manifest, .allowed = "BCDFNPQRTUZ", .required = "DZ" },
.{ .t = .cluster, .allowed = "MZ", .required = "MZ" },
.{ .t = .control, .allowed = "DTUZ", .required = "DTUZ" },
.{ .t = .wiki, .allowed = "CDLNPUWZ", .required = "DLUWZ" },
.{ .t = .ticket, .allowed = "DJKUZ", .required = "DJKUZ" },
.{ .t = .attachment, .allowed = "ACDNUZ", .required = "ADZ" },
.{ .t = .event, .allowed = "CDENPTUWZ", .required = "DEWZ" },
.{ .t = .forum, .allowed = "DGHINPUWZ", .required = "DUWZ" },
};
// inline: maskOfLetters needs its argument comptime-known, and rows is
// itself a comptime-known literal array.
inline for (rows) |row| {
try std.testing.expectEqual(maskOfLetters(row.allowed), allowed_mask.get(row.t));
try std.testing.expectEqual(maskOfLetters(row.required), required_mask.get(row.t));
}
}
test "artifact §5.2: a card outside the allowed set is rejected" {
const Row = struct { c: Census, want_type: ArtifactType };
// Each baseline satisfies its type's required set plus one letter
// outside the allowed set; the extra letter is never type-setting, so
// the finalized type is unaffected (event's extra, 'A', is
// type-setting but alphabetically precedes 'E' and so loses per r1).
const rows = [_]Row{
.{ .c = .{ .letters = maskOfLetters("BDWZ") }, .want_type = .manifest },
.{ .c = .{ .letters = maskOfLetters("CMZ") }, .want_type = .cluster },
// Not 'C': control has no type-setting letter, so (unlike every
// other row here) it reaches §5.3's fallback, and 'C' present
// would flip the fallback result to MANIFEST (p2).
.{ .c = .{ .letters = maskOfLetters("DTUWZ") }, .want_type = .control },
.{ .c = .{ .letters = maskOfLetters("DLTUWZ") }, .want_type = .wiki },
.{ .c = .{ .letters = maskOfLetters("CDJKUZ") }, .want_type = .ticket },
.{ .c = .{ .letters = maskOfLetters("ADPZ") }, .want_type = .attachment },
.{ .c = .{ .letters = maskOfLetters("ADEWZ") }, .want_type = .event },
.{ .c = .{ .letters = maskOfLetters("CDGUWZ") }, .want_type = .forum },
};
for (rows) |row| {
try std.testing.expectEqual(row.want_type, determineType(row.c));
try std.testing.expectError(error.DisallowedCard, classify(row.c));
}
}
test "artifact §5.2: a missing required card is rejected" {
const rows = [_]Census{
.{ .letters = maskOfLetters("BZ") }, // manifest, D missing
.{ .letters = maskOfLetters("M") }, // cluster, Z missing
.{ .letters = maskOfLetters("DUZ") }, // control, T missing
.{ .letters = maskOfLetters("LUWZ") }, // wiki, D missing
.{ .letters = maskOfLetters("JKUZ") }, // ticket, D missing
.{ .letters = maskOfLetters("AZ") }, // attachment, D missing
.{ .letters = maskOfLetters("DEZ") }, // event, W missing
.{ .letters = maskOfLetters("GUWZ") }, // forum, D missing
};
for (rows) |c| {
try std.testing.expectError(error.MissingRequiredCard, classify(c));
}
}
test "artifact §5.2: legality is checked against the finalized type" {
// M sets the type to CLUSTER; CLUSTER allows only M and Z, so the
// extra C card is rejected only if the check ran against CLUSTER, not
// some other type (C is legal on several other types).
const c = Census{ .letters = maskOfLetters("CM") };
try std.testing.expectEqual(ArtifactType.cluster, determineType(c));
try std.testing.expectError(error.DisallowedCard, classify(c));
}
test "artifact §5.2: a manifest with neither C nor U is accepted" {
const c = Census{ .letters = maskOfLetters("BDZ") };
try std.testing.expectEqual(ArtifactType.manifest, try classify(c));
}
test "artifact §5.2: a wiki without a U card is rejected" {
const c = Census{ .letters = maskOfLetters("DLWZ") };
try std.testing.expectError(error.MissingRequiredCard, classify(c));
}
test "artifact §5.2: an artifact with no Z card is rejected here" {
// Required-complete-minus-Z per type: even though §4.1's checksum
// gate tolerates an absent Z card, §5.2 rejects it for every type.
const rows = [_]Census{
.{ .letters = maskOfLetters("BD") },
.{ .letters = maskOfLetters("M") },
.{ .letters = maskOfLetters("DTU") },
.{ .letters = maskOfLetters("DLUW") },
.{ .letters = maskOfLetters("DJKU") },
.{ .letters = maskOfLetters("AD") },
.{ .letters = maskOfLetters("DEW") },
.{ .letters = maskOfLetters("DGUW") },
};
for (rows) |c| {
try std.testing.expectError(error.MissingRequiredCard, classify(c));
}
}
test "artifact §5.3: the alphabetically latest type-setting card wins" {
try std.testing.expectEqual(ArtifactType.cluster, determineType(.{ .letters = maskOfLetters("AM") }));
try std.testing.expectEqual(ArtifactType.wiki, determineType(.{ .letters = maskOfLetters("BL") }));
try std.testing.expectEqual(ArtifactType.forum, determineType(.{ .letters = maskOfLetters("EG") }));
try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("AB") }));
}
test "artifact §5.3: fallback resolves by C/P presence" {
try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("C") }));
try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("P"), .p_parents = 1 }));
try std.testing.expectEqual(ArtifactType.control, determineType(.{ .letters = maskOfLetters("DZ") }));
// P card present but bare (zero parent tokens, §4.7.13 p1) does not
// count as "P card parents recorded".
try std.testing.expectEqual(ArtifactType.control, determineType(.{ .letters = maskOfLetters("P"), .p_parents = 0 }));
}
test "artifact §5.3: §5.2 legality runs after the fallback finalizes the type" {
// No C, no P parents -> fallback to CONTROL; N is not in CONTROL's
// allowed set, so this can only fail as DisallowedCard if the check
// ran against the fallback-finalized type.
const control_case = Census{ .letters = maskOfLetters("DNTUZ") };
try std.testing.expectEqual(ArtifactType.control, determineType(control_case));
try std.testing.expectError(error.DisallowedCard, classify(control_case));
// C present -> fallback to MANIFEST; W is not in MANIFEST's allowed
// set.
const manifest_case = Census{ .letters = maskOfLetters("CDWZ") };
try std.testing.expectEqual(ArtifactType.manifest, determineType(manifest_case));
try std.testing.expectError(error.DisallowedCard, classify(manifest_case));
}
test "artifact §5.4: a CONTROL artifact with a self-referential T card is rejected" {
const c = Census{ .letters = maskOfLetters("DTUZ"), .t_self_ref = true };
try std.testing.expectError(error.ControlSelfTag, classify(c));
const ok = Census{ .letters = maskOfLetters("DTUZ"), .t_self_ref = false };
try std.testing.expectEqual(ArtifactType.control, try classify(ok));
}
test "artifact §5.4: an EVENT artifact with a non-self-referential T card is rejected" {
const c = Census{ .letters = maskOfLetters("DETWZ"), .t_not_self_ref = true };
try std.testing.expectError(error.EventTagNotSelfRef, classify(c));
}
test "artifact §5.4: an EVENT artifact with a T card lacking the + prefix is rejected" {
const c = Census{
.letters = maskOfLetters("DETWZ"),
.t_self_ref = true,
.t_not_plus = true,
};
try std.testing.expectError(error.EventTagNotPlus, classify(c));
const ok = Census{
.letters = maskOfLetters("DETWZ"),
.t_self_ref = true,
.t_not_plus = false,
};
try std.testing.expectEqual(ArtifactType.event, try classify(ok));
}
test "artifact §5.4: a FORUM artifact with both H and I cards is rejected" {
const c = Census{ .letters = maskOfLetters("DHIUWZ") };
try std.testing.expectError(error.ForumHeadAndReply, classify(c));
}
test "artifact §5.4: a FORUM artifact whose P card lists more than one parent is rejected" {
const rejected = Census{ .letters = maskOfLetters("DGPUWZ"), .p_parents = 2 };
try std.testing.expectError(error.ForumMultiParent, classify(rejected));
const ok = Census{ .letters = maskOfLetters("DGPUWZ"), .p_parents = 1 };
try std.testing.expectEqual(ArtifactType.forum, try classify(ok));
}
test "artifact §5.2/§5.4: a well-formed artifact of each type is accepted" {
const Row = struct { c: Census, t: ArtifactType };
const rows = [_]Row{
.{ .c = .{ .letters = maskOfLetters("BDZ") }, .t = .manifest },
.{ .c = .{ .letters = maskOfLetters("MZ") }, .t = .cluster },
.{ .c = .{ .letters = maskOfLetters("DTUZ") }, .t = .control },
.{ .c = .{ .letters = maskOfLetters("DLUWZ") }, .t = .wiki },
.{ .c = .{ .letters = maskOfLetters("DJKUZ") }, .t = .ticket },
.{ .c = .{ .letters = maskOfLetters("ADZ") }, .t = .attachment },
.{ .c = .{ .letters = maskOfLetters("DEWZ") }, .t = .event },
.{ .c = .{ .letters = maskOfLetters("DGUWZ") }, .t = .forum },
};
for (rows) |row| {
try std.testing.expectEqual(row.t, try classify(row.c));
}
}
test "artifact §4.5.2/§A.1.1: repeatable-card fields expose wire-order runs" {
// §4.7.6: a baseline manifest's F cards all carry a hash (deferred
// enforcement -- see assertNoDeleteMarkers) -- give both a hash so this
// stays a legal artifact under that eventual rule, not just today's parse.
var buf: [200]u8 = undefined;
const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nF alpha.txt " ++ A_CARD_HASH40 ++ "\nF beta.txt " ++ HASH40_HIGH ++ "\n");
var m = try parse(std.testing.allocator, bytes);
defer m.deinit();
try std.testing.expectEqual(ArtifactType.manifest, m.kind);
try std.testing.expectEqual(@as(usize, 2), m.f.len);
try std.testing.expectEqualStrings("alpha.txt", m.f[0].filename);
try std.testing.expectEqualStrings("beta.txt", m.f[1].filename);
}