Artifact b04119b2679887c6705ed4831899ca1911ca20afe0bac865022ac8b7ad879194:
- File
src/sync.zig
— part of check-in
[d91a3cfe42]
at
2026-07-17 13:43:27
on branch clone-w4
— Skip the receive-side inflate for full-form clone content
The clone/sync receive path inflated every stored cfile container to prove the RFC1950 stream was intact (§3.9), then threw the result away: the store keeps the container verbatim and reconstructs lazily, and the persist commit hook runs the §7.7 integrity check on the reconstructed bytes. For the full form that inflate was pure waste. Move the decode into the delta branch (which genuinely needs the plaintext to reconstruct) so a full-form cfile is interned and retained without inflating.
Retaining an unvalidated container means a held artifact is no longer guaranteed reconstructable: content() can be null for a corrupt wire-backed container, so held() no longer implies content(). Reconcile the consumers that assumed it did. The pull .raw delta basis and the push upload path both degrade gracefully (round abort / silent skip) instead of tripping an unreachable or assert on hostile input; the read/derivation path keeps the decompression-bomb cap, and the eager clone-end image treats a corrupt container as an error.
Cuts clone peak RSS ~13% (651 -> 568 MB) on the fossil corpus by dropping the discarded plaintext allocation. Instruction count and wall are flat, since full-form cfiles are the minority of clone content. 1538 tests pass; fossil interop smoke green. (user: dmestas, size: 306538) [annotate] [blame] [check-ins using] [more...]
//! Sync Protocol — draft-fossil-sync-protocol-00. //! //! Two repositories reconcile a content-addressed artifact set over HTTP. The //! protocol is client-driven; the server is stateless across rounds, so only //! the client runs a state machine (Section 3.8). const std = @import("std"); const artifact = @import("artifact.zig"); const repostate = @import("repostate.zig"); const delta = @import("delta.zig"); const codec = @import("container.zig"); /// The client's protocol state (Section 3.8). The server reacts to each /// request independently and has no state machine. pub const ClientState = enum { /// Operation selected; no round sent yet. init, /// First round of a modern clone: login suppressed, config/uv-hash /// deferred, an `error` reply ignored (Sections 8.3, 8.4, 11.1). clone_round0, /// Steady round loop: authenticated request, reply dispatched, phantom / /// send-queue / config / private work advances (Section 3.2). exchange, /// Clean stop: no continuation condition of Section 3.7 held. terminated, /// A fatal protocol error ended the sync without convergence. aborted, }; /// The operation a sync round is performing. pub const Operation = enum { pull, push, sync, clone }; /// §9.1/§12 — the upper bound on a received project code: an artifact hash, /// so at most the SHA3-256 hex width (64). The clone client copies the /// peer's project code into a fixed buffer of this size; a longer field is /// out of spec and ignored (bounded, never truncated into storage). pub const max_projectcode_len: usize = 64; pub const Error = error{ FatalProtocolError, NotAuthorized, NotImplemented }; /// Robustness posture (Section 3.9): conservative in what it sends, strict in /// what it accepts. An unknown non-`pragma` operator and a malformed content /// card are fatal; liberality is confined to the four enumerated points. pub const Client = struct { state: ClientState = .init, operation: Operation = .sync, /// §3.2/§9.1 L2035-2037 — this client's own repository identifiers, /// emitted verbatim in the pull/push operation cards (SERVERCODE then /// PROJECTCODE). Set once at session init; `buildRequest` reads them and /// never writes them. self_servercode: []const u8 = "", project_code: []const u8 = "", /// §8.3/§8.4 L692, §9.1 — the PEER's project code, learned from the /// `push SERVERCODE PROJECTCODE` card a clone reply closes with (§8.2). /// A fresh clone carries none of its own until it records the peer's here /// (the shared identifier, §9.1); the CLI reads `peerProjectCode` after /// the run and persists it into the new repository's config. An inline, /// `max_projectcode_len`-bounded copy — no heap, since the wire slice is /// freed at the round boundary. peer_project_code_buf: [max_projectcode_len]u8 = undefined, peer_project_code_len: usize = 0, /// §10.1 L3206-3217 — the client release/timestamp emitted as the leading /// `pragma client-version` line of every request. version: VersionInfo = .{ .version = 0 }, /// §6.1/§6.3 — the login credential, or null for an anonymous/nobody /// session (which emits no login card, §6.1 L1933-1935). Sans-IO: the /// plaintext password never lives here, only the derived shared secret. credential: ?Credential = null, /// §5.3 L1789-1793 — the per-round randomness comment appended after the /// content cards, so the login nonce (derived over the post-login body) /// differs between otherwise-identical rounds. Null in the sans-IO core: /// there is no RNG here, so an outer layer injects it. round_comment: ?[]const u8 = null, /// §8.2 L1896-1937 — the clone sequence number the next clone request /// carries (`clone 3 SEQNO`); a received clone_seqno 0 ends the clone, so /// a request built at seqno 0 carries no clone card (the cleanup round). clone_seqno: i64 = 1, // The round loop is a sans-IO state machine: `consumeReply` takes a reply's // bytes and updates `state`; the request-emission half and the transport // (real HTTP or the simulator) are the caller's — the server is stateless, // only the client keeps state (Section 3.8). /// §3.3 r24 — the client-side phantom set, keyed by store Rid. It persists /// across rounds: the §3.7(a) phantom table lives on the client, not in the /// per-round tables. An igot creates an entry; arriving content removes it. /// Rid is a stable key — `intern` returns the existing rid for a known hash /// and `put` interns first, so the rid an igot recorded is the rid the /// later `file` payload removes. The value owns no heap, so `remove`/`deinit` /// ignore it. phantoms: std.AutoHashMapUnmanaged(repostate.Rid, PhantomState) = .empty, /// §3.1 L605-608, §3.2 L763-766 — the per-session outbound send buffer: /// rids of held artifacts the peer requested with gimme and this side may /// push. It persists across rounds (never reset at a round boundary) and is /// drained when the caller builds the next request. Rid is a stable key — /// `store.lookup` resolves the gimme'd hash to the rid its content lives /// under, so what is queued is exactly what gets uploaded. send_queue: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty, /// §3.3 r24 — the running count of full-content `file` cards this client has /// uploaded across the session. Only a content-sending operation queues sends /// (`pushActive`), so this stays 0 for a pull or clone; each queued rid drains /// and uploads exactly once, so the sum is the number of distinct artifacts /// pushed — the `zit push` yield metric. A round boundary never resets it. content_sent: usize = 0, /// §7.4/§3.3 — deltas received against a basis this client does not yet hold, /// retained until the basis arrives and then reconstructed to a fixpoint. It /// persists across rounds (the basis is gimme'd in a later round), owns a /// private copy of each retained delta, and derives §3.3 content-received via /// `hasTarget` so a dangling target is not re-requested. pending: PendingDeltaQueue = .{}, /// §10.1 r13 — the peer record, updated by each server-version pragma. peer: Peer = .{ .version = 0 }, /// §5.3, §A.4 — the last `# timestamp DATETIME errors N` comment seen; read /// for clock skew (the skew computation itself is deferred). last_timestamp: ?TimestampComment = null, /// §11.2 item 1/3 — a one-way SYNC_PUSH latch. A `pull only *` message /// received while push intent is live sets this for the rest of the session /// (r6); a round boundary never resets it, and setting it edits no send- /// buffer bytes (r10). The live push flag is derived with `operation` in /// `pushActive`, so no construction site restates the operation->push map. push_cleared: bool = false, /// §3.6 L913-916 — the client's single held sync cookie. A server reply's /// cookie card overwrites it; a round boundary never resets it. Inline /// buffer, no heap — `deinit` needs no change for it. cookie: CookieStore = .{}, /// §7.5 L2359-2367 — client receive-side pending private marker. Per-session: /// persists across consumeReply bodies (r5); the next file/cfile card consumes /// it at handler entry (r4), a fresh Client (new session) starts clear (r6). A /// bare private card sets it, tolerant of intervening comment/non-content /// cards (r2), and a repeat leaves it set (r3). A round boundary never resets /// it — same per-session lifetime as push_cleared/cookie. private_pending: bool = false, /// §7.5 L2381-2385 — the receive-side store gate: true once the server has /// accepted a send-private pragma (the setter is the pragma-round batch's /// job; B3 only consumes it as the store-vs-discard decision). Per-session: /// a fresh Client starts false and a round boundary never resets it, the /// same lifetime as `private_pending`. For the client receiver this is the /// operational encoding of "holds Private" — §10.5 only accepts send-private /// when the client holds the capability. private_granted: bool = false, /// §7.5 L2478-2480 — per-phantom privacy. Map presence is still the phantom /// marker (content-absent, §3.3 r24); this value carries the private flag a /// private `igot HASH 1` sets, so §3.3(c) gimme-eligibility stops re- /// requesting content that will never be sent. Default public. const PhantomState = struct { private: bool = false }; /// §3.2/§3.7/§3.8 — the closed error set `consumeReply` returns, so the CLI /// can switch it exhaustively. `NotImplemented` fences the out-of-scope-but- /// legal reply cards (cfile/uvfile/private/uvigot/uvgimme and the delta-form /// file card) as a strict boundary — a legal card is never dispatched to a /// wrong answer. Auth failures never surface here: they arrive as `error` /// cards and route through `clientOnErrorCard`. pub const ConsumeError = error{ OutOfMemory, FatalProtocolError, NotImplemented }; /// Frees the phantom set and the send buffer. The Rid keys own no memory, /// so this is just the maps' own storage. pub fn deinit(self: *Client, gpa: std.mem.Allocator) void { self.phantoms.deinit(gpa); self.send_queue.deinit(gpa); self.pending.deinit(gpa); self.* = undefined; } /// §8.3/§9.1 — the peer's project code as learned from a received /// `push`/`pull` card, or empty if none was seen this session. Borrows /// the client's own inline buffer; valid until the next such card. pub fn peerProjectCode(self: *const Client) []const u8 { return self.peer_project_code_buf[0..self.peer_project_code_len]; } /// §3.1 L605-608, §3.2 L763-766 — copy the send buffer's rids into `out` /// and empty the buffer. The caller sizes `out` from `send_queue.count()` /// and encodes one file card per rid for the next request; returns the /// count written. Total and allocation-free — the capacity bound is /// asserted, never risked. The buffer is emptied only here (an explicit /// upload), never by a round boundary, per §3.1's per-session rule. pub fn drainSendQueue(self: *Client, out: []repostate.Rid) usize { const count = self.send_queue.count(); std.debug.assert(out.len >= count); var i: usize = 0; var it = self.send_queue.keyIterator(); while (it.next()) |rid| { out[i] = rid.*; i += 1; } std.debug.assert(i == count); self.send_queue.clearRetainingCapacity(); std.debug.assert(self.send_queue.count() == 0); return count; } /// §11.2 item 1/3a — the single live-SYNC_PUSH predicate every push-gated /// append site reads: push intent is live iff the operation direction /// sends content (§3.3) AND no `pull only *` message has cleared it. Once /// cleared it stays cleared (r6), so the two conditions never both hold. pub fn pushActive(self: *const Client) bool { const active = clientSendsContent(self.operation) and !self.push_cleared; return active; } /// §3.2/§3.8 — the closed error set `buildRequest` returns. `OutOfMemory` /// covers the growable output buffer; `RequestTooLarge` fences a request /// whose body or any card line would exceed the §4 L1148-1149 message-body /// ceiling. No auth or protocol error appears here: composing a request /// cannot fail those ways. pub const BuildError = error{ OutOfMemory, RequestTooLarge }; /// §3.2 — compose one request body from Client and store state, the /// send-direction inverse of `consumeReply`. Sans-IO: bytes out, no /// transport. The layout is the client-version pragma (§10.1, always /// first), an optional body login card (§6.1, only for a named identity on /// an un-gated peer), then the operation card(s), an `igot` per held /// artifact (pull/push/sync only; §8.2 L1417-1427 clone announces none), a /// `gimme` per eligible phantom, and a `file` per queued send /// (§3.2 L688-694). `out` is a reused backing buffer, cleared here; the /// returned slice is `out.items`. Mutates only `send_queue` (drained) and /// reads the rest — it does not advance `ClientState` (the driver decides, /// via §3.7, whether to call again). pub fn buildRequest( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, out: *std.ArrayListUnmanaged(u8), ) BuildError![]const u8 { out.clearRetainingCapacity(); const line_buf = try gpa.alloc(u8, max_line_len); defer gpa.free(line_buf); // §10.1 L3206-3217 / §3.2 r22 L657-664 — the client-version pragma is // the first line and precedes every content-requesting card. const pragma = writeClientVersion(self.version, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, out, pragma); // §6.1 L1919-1935 / §6.5 — a body login card is emitted only for a // named identity, on a non-clone-round-0 request, against an un-gated // peer; anonymous, cookie-gated, and clone-round-0 requests carry none // in the body. The nonce (§6.2) covers the bytes after the login LF, so // the post-login body is built first, hashed, then prefixed. const identity: Identity = if (self.credential) |c| Identity.fromLoginName(c.user) else .anonymous; const round: u64 = if (self.state == .clone_round0) 0 else 1; if (loginTransport(identity, self.peer, self.operation, round) == .body) { const cred = self.credential.?; var tail: std.ArrayListUnmanaged(u8) = .empty; defer tail.deinit(gpa); try self.appendBody(gpa, store, &tail, line_buf); if (tail.items.len > reply_body_cap) return error.RequestTooLarge; const nonce = computeNonce(tail.items); const signature = computeSignature(&nonce, &cred.secret_hex); const login = writeLogin( .{ .user = cred.user, .nonce = &nonce, .signature = &signature }, line_buf, ) catch return error.RequestTooLarge; try appendCard(gpa, out, login); try out.appendSlice(gpa, tail.items); } else { try self.appendBody(gpa, store, out, line_buf); } if (out.items.len > reply_body_cap) return error.RequestTooLarge; std.debug.assert(out.items.len >= pragma.len + 1); // the pragma line is always present return out.items; } /// §3.2 L688-694 — append the post-login body (everything the nonce covers) /// into `buf`: operation card(s), then `igot` for every held artifact /// (pull/push/sync only — a clone round announces none, §8.2 L1417-1427), /// `gimme` for every eligible phantom, `file` for every queued send, and /// finally the §5.3 randomness comment when one is set. Never emits the /// pragma or login card — those are the caller's framing. fn appendBody( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, buf: *std.ArrayListUnmanaged(u8), line_buf: []u8, ) BuildError!void { // §3.2 L657-664 — operation card(s): pull, push, or both for a // bidirectional sync; for clone a `clone 3 SEQNO`, unless the round is // a post-clone_seqno-0 cleanup (§8.2 L1930-1934), which carries none. switch (self.operation) { .pull => try self.appendPushPull(gpa, buf, line_buf, .pull), .push => try self.appendPushPull(gpa, buf, line_buf, .push), .sync => { try self.appendPushPull(gpa, buf, line_buf, .pull); try self.appendPushPull(gpa, buf, line_buf, .push); }, .clone => switch (nextRequestKind(true, self.clone_seqno)) { .clone_card => { std.debug.assert(self.clone_seqno > 0); const seqno: u64 = @intCast(self.clone_seqno); const card = writeCloneCard(seqno, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); }, .cleanup_round => {}, // §8.2 L1930-1934 — no operation card .stop => unreachable, // a request is composed only while §3.7 continues }, } const count: repostate.Rid = store.count(); // §3.2 L696, §A.1 L4099-4101 — a pull/push/sync request announces every // held artifact with igot: §8.5 L1480-1481 makes the announcement live // (a server creates its phantoms from a client igot only under push). // §8.2 L1417-1427 — a clone session enables no push direction (its // `clone 3 SEQNO` carries no push card), so a clone-round igot creates // no server phantom and never drives the reply, which §8.2 L1384-1387 // builds solely from the RID-cursor cfile sequence. The §8.2 L1422 // cleanup "remaining-work" igot permission is therefore never applicable // to clone; the walk is skipped so a clone round carries only its // operation card(s), pragmas, gimme, and randomness. if (self.operation != .clone) { var rid: repostate.Rid = 1; while (rid <= count) : (rid += 1) { if (store.held(rid)) { const card = writeIgot(store.hashOf(rid), false, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); } } } // §3.2 L690-692, §3.3 L826-846 — request every eligible phantom with // gimme. The store IS the phantom set (§3.3 r24: content == null); the // private flag, when known, rides on the Client phantom record. A // phantom is only ever created under a receive-capable operation // (§3.3 r24(1)), so the store's phantom set already encodes receive // eligibility — no separate direction gate is needed here. The // peer-lacks and shun filters (§3.3 (a)/(b)) are empty when composing a // request: no peer gimme has been observed for this prospective round. var rid: repostate.Rid = 1; while (rid <= count) : (rid += 1) { if (!store.held(rid)) { // §3.3/§7.4 OPTION B — a phantom whose hash is a retained delta's // TARGET is content-received (its bytes reconstruct on basis // arrival), so it is excluded from gimme; the DELTASRC phantom it // depends on is not a target and stays eligible. if (self.pending.hasTarget(store.hashOf(rid))) continue; const private = if (self.phantoms.get(rid)) |ps| ps.private else false; if (gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = private, .private_sync_enabled = self.private_granted, })) { const card = writeGimme(store.hashOf(rid), line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); } } } // §3.2 L693-694, §7.1 — a full-content file card per queued send. The // send buffer holds rids the gimme gate proved materialisable (§3.1 // L605-608 keys the send decision on `content()`, not bare `held()`), so // `content()` is expected non-null here; drain it into a bounded scratch // first. const queued = self.send_queue.count(); if (queued > 0) { const rids = try gpa.alloc(repostate.Rid, queued); defer gpa.free(rids); const drained = self.drainSendQueue(rids); std.debug.assert(drained == queued); for (rids[0..drained]) |send_rid| { // §7.7 — a wire-backed container may be held yet not // reconstructable (a W4 corrupt full container: content()==null). // The gimme gate already excludes such a rid, so this is // defence-in-depth: skip it rather than upload corrupt bytes or // trap on a null deref — never assert content is present. const content = store.content(send_rid) orelse continue; writeFile(gpa, buf, store.hashOf(send_rid), content) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoSpaceLeft => return error.RequestTooLarge, }; self.content_sent += 1; // §3.3 r24 — one artifact uploaded this round } } // §5.3 L1789-1793 — the randomness comment closes the body; null in the // sans-IO core, so this is a no-op there. if (self.round_comment) |comment| { const card = std.fmt.bufPrint(line_buf, "# {s}", .{comment}) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); } } /// §9.1 L2035-2037 — append one `push|pull SERVERCODE PROJECTCODE` /// operation card, framed by its LF. fn appendPushPull( self: *Client, gpa: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), line_buf: []u8, dir: PushPull, ) BuildError!void { const card = writePushPullCard(dir, self.self_servercode, self.project_code, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); } /// §3.2/§3.7/§3.8 — consume one reply body: dispatch each card, apply store /// effects, evaluate the §3.7 continuation, advance the §3.8 state. Bytes in /// / RoundObservation out; the transport is the caller's (sans-IO). /// Precondition: a reply is dispatched only mid-exchange (`exchange`) or in /// a clone's first round (`clone_round0`) — the two reply-bearing states of /// §3.8 Table 3. pub fn consumeReply( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, body: []const u8, ) ConsumeError!RoundObservation { std.debug.assert(self.state == .exchange or self.state == .clone_round0); var rt = RoundTables.init(gpa); defer rt.deinit(); var obs = RoundObservation{ .operation = self.operation }; // §5.1 — the body is a flat LF-delimited card stream; the cursor strictly // advances (a line consumes ≥1 byte through its LF; a file card also // consumes its byte-counted payload), so the loop is bounded by body.len. var cursor: usize = 0; while (cursor < body.len) { std.debug.assert(cursor < body.len); const line = readLine(body, cursor) catch return self.abort(); var next_cursor = line.next; if (isCommentLine(line.bytes)) { // §5-r25, §A.4 — a timestamp comment updates the skew record; // any other comment (nonce, `# sending-clusters`) is skipped. if (parseTimestampComment(line.bytes)) |tc| self.last_timestamp = tc; } else { const cc = classifyCard(line.bytes); if (cc.operator == null or isFatal(cc.disposition, .client)) return self.abort(); next_cursor = try self.dispatchCard(gpa, store, &rt, &obs, cc.operator.?, cc.tokens, body, line.next); } std.debug.assert(next_cursor > cursor); cursor = next_cursor; } std.debug.assert(cursor == body.len); // §7.5 L2369-2371 — a body may end with private_pending still set (no // file/cfile card arrived to consume it): not by itself a fatal error, and // it is deliberately not cleared here — it persists to the next reply body // in this session (r5). // §3.7(a)/(b) — the continuation flags read the final table state. A // phantom created earlier in the body may have been dephantomized by a // later file card, so phantom_created does not imply a non-empty table; // phantom_table_nonempty, computed here, is the sole §3.7(a) authority. obs.phantom_table_nonempty = self.phantoms.count() > 0; if (self.state == .clone_round0) { // §3.8 r14 — a processed clone reply advances unconditionally. self.state = transition(.clone_round0, .clone_reply_processed); } else { const ev: Event = if (continueExchange(obs)) .round_continues else .round_terminates; self.state = transition(.exchange, ev); } return obs; } /// Dispatch one classified card (§3.2), returning the offset the loop /// resumes at — the payload end for a `file`/`cfile` card, otherwise the /// next line. /// Exhaustive over all 19 operators: no reply card is silently unhandled. fn dispatchCard( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, rt: *RoundTables, obs: *RoundObservation, op: Operator, tokens: Tokens, body: []const u8, payload_start: usize, ) ConsumeError!usize { switch (op) { .pragma => { // §10.1 — record a server/client-version pragma; §3.5 — any // other pragma name is tolerated and ignored here. if (tokens.len >= 2 and isVersionPragmaName(tokens.items[1])) { if (parseVersionPragma(tokens)) |v| recordPeerVersion(&self.peer, v) else |_| {} } return payload_start; }, .push, .pull => { // §9.1 — arity-validate (arity < 3 is malformed → fatal). // §8.3/§8.4 L692 — a clone reply's `push` card is how the // client first learns the peer's project code (§8.2/§9.1), so // record it into the session's inline buffer (bounded copy; // the wire slice dies at the round boundary). The CLI reads it // via `peerProjectCode` after the run to persist the shared // identity into the fresh repository (§9.1). const card = parsePushPull(tokens) orelse return self.abort(); if (card.projectcode.len <= max_projectcode_len) { @memcpy(self.peer_project_code_buf[0..card.projectcode.len], card.projectcode); self.peer_project_code_len = card.projectcode.len; } return payload_start; }, .clone => { // §3.9 — a client→server request-initiation card is never legal // in a server reply: a strict-accept protocol violation, not an // unimplemented feature. return self.abort(); }, .file => { // §7.5 L2363 — the marker is consumed the moment file handling // begins, before arity/hash validation; the delta-form card // therefore consumes it too, which is correct: it is still a file // card whose handling began. const was_private = self.takePrivateMarker(); // §7.1-r1/r5 — full form (file HASH SIZE) or delta form // (file HASH DELTASRC SIZE); arity alone disambiguates. SIZE // frames the uncompressed payload identically in both. const fc = parseFileCard(tokens) catch return self.abort(); const payload = consumeContentPayload(body, payload_start, fc.size) catch return self.abort(); // §8.2 L1406-1409 — a framed file card increments the raw clone // received-artifact count before the store/discard split // (§3.7(e), read by continuesClone alongside the cursor). obs.artifact_count_grew = true; // §7.5 L2383-2385 — a private artifact without the grant is // discarded without error: bytes consumed (framing ran first so // the parser still advances), store untouched, phantom left // intact, no versioned progress. Applies to both forms. if (was_private and privateReceipt(self.private_granted) == .discard) { obs.private_content_discarded = true; return payload.next; } if (fc.deltasrc) |deltasrc| { // §7.1-r5/§7.4 — delta form: the payload is a [FossilDelta] // against DELTASRC. Reconstruct against a held basis or defer. // A file card is uncompressed (§7.1): no §4.1 container to // retain verbatim, so pass null (persists as full form). try self.receiveDelta(gpa, store, obs, fc.hash, deltasrc, payload.bytes, was_private, null); return payload.next; } const rid = try store.put(fc.hash, payload.bytes); // §3.7(a) — arriving content dephantomizes; the remove is a // no-op for a hash never phantom'd (underflow defined away). _ = self.phantoms.remove(rid); obs.versioned_content_delivered = true; obs.artifact_received_this_round = true; // §3.7(e) — a stored file delivery this round if (was_private) obs.private_content_delivered = true; // §7.5 L2359 // §7.4 — a full-content store may itself be a retained delta's // basis; complete the chain to a fixpoint. self.pending.completeFor(gpa, store, store.hashOf(rid)) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.MalformedDelta, error.ChecksumMismatch => return self.abort(), }; return payload.next; }, .igot => { // §3.9 strict-accept — parse precedes the canObtain guard so a // malformed igot is fatal in any direction, push-side included. const card = parseIgotCard(tokens) catch return self.abort(); if (card.private and !self.private_granted) { // §7.5 L2476-2481 — a private `igot HASH 1` from a peer that // LACKS private-content authorization (at this client // receiver, private_granted false: no accepted send-private // pragma, so the content will never be sent here) is never // an error and never creates a phantom (L2478 → lookup, not // intern); it marks an already-held phantom private so // §3.3(c) stops re-requesting it, records that the peer // holds HASH, and processing continues. const maybe_rid = store.lookup(card.hash); const pptr: ?*PhantomState = if (maybe_rid) |rid| self.phantoms.getPtr(rid) else null; const receipt = receivePrivateIgot(pptr != null); std.debug.assert(!receipt.create_phantom and receipt.record_peer_has); // negative space if (receipt.mark_phantom_private) { pptr.?.private = true; obs.phantom_marked_private = true; } obs.private_igot_peer_has = receipt.record_peer_has; return payload_start; } // §3.3 r24/r25 — a public igot, or (§7.5 L2481-2483) a private // igot when this receiver holds private-content authorization // (private_granted, so it may pull the private content), creates // a phantom only when the artifact is unheld and this side can // obtain it. The guard skips interning for a push-side reply the // client can never obtain from — an intern-avoidance // optimization over the canObtain check inside igotDisposition, // not dead logic. if (!igotCanObtain(.client, self.operation)) return payload_start; const rid = try store.intern(card.hash); if (igotDisposition(.client, self.operation, store.held(rid)) == .create_phantom) { const gop = try self.phantoms.getOrPut(gpa, rid); if (!gop.found_existing) { gop.value_ptr.* = .{}; obs.phantom_created = true; } } return payload_start; }, .gimme => { // §3.3 L848 — a gimme always records that the peer lacks HASH. // The 40/64-hex length recordPeerLacks requires is enforced by // parseGimmeCard, so it is never asserted on wire input. const g = parseGimmeCard(tokens) catch return self.abort(); try rt.recordPeerLacks(g.hash); // §3.2 L761-766 / §3.3 L849-856 — if this side holds HASH and // the operation direction permits sending, queue its rid in the // per-session send buffer (§3.1 L605-608) for the next request. // lookup, not intern: an unheld or garbled gimme creates no // phantom, keeping §3.3's phantom set closed. §3.7(b): a fresh // append this round sets the per-round content_queued flag. if (store.lookup(g.hash)) |rid| { // Queue only a rid we can MATERIALISE for upload. `held()` is // true for a wire-backed container even when `content()` is // null — a W4 (§7.2/§7.7) full container retained verbatim // that fails to inflate — but uploading needs reconstructable // plaintext (§3.2 L358-360 composes from `content()`), so the // send gate reads `content()`, not bare `held()`. A held-but- // uninflatable hash folds to the same byte-neutral silent-skip // (§3.3) as an unheld gimme: not queued, no corrupt bytes sent. const holds = store.content(rid) != null; // policy_permits is true: shun/private disclosure gating is // not modeled at this layer yet, as at every gimmeResponse // call site. // §11.2 r11 — the send gate reads the *live* push flag, not // the immutable operation intent: a `pull only *` message // earlier in this same reply clears it and suppresses a // later gimme's content append. r12 (an earlier gimme's // append is retained) falls out of in-order dispatch. if (gimmeResponse(holds, self.pushActive(), true) == .send) { const gop = try self.send_queue.getOrPut(gpa, rid); if (!gop.found_existing) obs.content_queued = true; } } return payload_start; }, .clone_seqno => { // §8.2 L2010-2013, §3.8 r19 — validate NEXT's sign at parse: a // negative wire value is a fatal protocol error, never stored // into obs.clone_seqno_next where continueExchange's `>= 0` // precondition would otherwise trip on wire data. const n = parseCloneSeqno(tokens) catch return self.abort(); if (cloneSeqnoIsFatal(n)) return self.abort(); std.debug.assert(n >= 0); obs.clone_seqno_next = n; return payload_start; }, .@"error" => { // §11.1 — an error card is data, not a directive: discarded in a // clone's first round, otherwise it aborts the exchange. return switch (clientOnErrorCard(self.state)) { .discard => payload_start, .terminate => self.abort(), }; }, .message => { // §11.2 L3543 — "The card MUST consist of exactly two whitespace- // separated tokens". A different visible arity is a defined-operator // arity failure (§5.2 L1771-1773) the client aborts on (§5.2 // L1809-1812, §3.9 L1315): a 1-token bare `message` or a 3-to-6-token // form. classifyCard's §5 surplus check already caught a 7th+ token // pre-dispatch. The r3 "never aborts" disposition governs only the // well-formed two-token card below. if (tokens.len != 2) return self.abort(); const encoded = tokens.items[1]; std.debug.assert(encoded.len <= max_line_len); // alloc bound // §11.2 r2 — defossilize before matching; the decoded form never // grows past the encoded token, and the scratch is freed here. const scratch = try gpa.alloc(u8, encoded.len); defer gpa.free(scratch); const decoded = artifact.decodeEscapes(encoded, scratch); // §11.2 item 1/2 — clear SYNC_PUSH iff push is live and the // payload matches `pull only *` (r4); otherwise the advisory // path (r9), which changes no flag. This sans-IO layer performs // no display, so r5 suppression holds trivially and r9's // MAY-display is the CLI caller's choice. switch (clientOnMessageCard(self.pushActive(), decoded)) { .pull_only_silent => { self.push_cleared = true; // r6/r10/r13 — one flag, latched std.debug.assert(!self.pushActive()); // now clear }, .display_optional => {}, } return payload_start; }, .cookie => { // §3.6 L913-916, §4.3 — the server MAY emit a cookie; the // client MUST store the opaque TEXT, replacing any held one. // §B.4 L4622 `cookie-card = "cookie" SP fossilized LF`: a bare // `cookie` with no TEXT fails the card's arity check, a fatal // client abort (§5.2 L1809-1813). §3.9 L1312 tolerance is scoped // to an unexpected server, not to a missing argument token. if (tokens.len < 2) return self.abort(); std.debug.assert(tokens.items[1].len <= max_line_len); self.cookie.storeFossilized(tokens.items[1]); obs.cookie_stored = true; // §5.1 — cookie carries no byte-counted payload; resume at the // next line, not past any content bytes. return payload_start; }, .login, .reqconfig => { // §6.1 L1919-1920 (login is a request-only card, "carries // exactly one login card") / §10.3 L3272 (reqconfig is // client→server, "A client sends reqconfig NAME"). Either in a // server reply is a strict-accept violation (§3.9 L1314-1315: // "a receiver applies a defined rule or aborts"). return self.abort(); }, .config => { // §10.3 L3278/L3320-3322 — `config GROUPNAME SIZE` then SIZE // content bytes and one mandatory trailing LF. Frame by the // byte count, never by scanning; a malformed header or a frame // past the body is a fatal content error (§3.9). const cc = parseConfigCard(tokens) catch return self.abort(); const p = consumeConfigPayload(body, payload_start, cc.size) catch return self.abort(); return p.next; }, .cfile => { // §7.5 L2363 — consume the pending marker at cfile handler entry, // before arity/hash validation (see the file arm and // takePrivateMarker), so the staged delta form clears it as well. const was_private = self.takePrivateMarker(); // §7.2-r1/r2 — full form (cfile HASH USIZE CSIZE) or delta form // (cfile HASH DELTASRC USIZE CSIZE); arity alone disambiguates. // Any other arity or a non-numeric USIZE|CSIZE is a fatal // strict-accept protocol error (§3.9, §5.2). const cc = parseCfileCard(tokens) catch return self.abort(); // §7.2-r2/r3/r11 — frame by CSIZE, never USIZE; a span past the // body end is a fatal content error (§3.9). const payload = consumeContentPayload(body, payload_start, cc.compressed_size) catch return self.abort(); // §5.1 L877-879 — resume past the payload AND its optional // trailing-LF padding; keyed on the payload's last byte so a // following real card (or empty line) is never mis-skipped. const resume_at = cfilePaddingEnd(body, payload); // §8.2 L1406-1409 — the clone received-artifact count is a raw // handler count: a framed cfile increments it here, *before* the // store/discard split, so a verified duplicate or policy- // discarded cfile counts too. continuesClone (§3.7(e)) pairs it // with a positive `clone_seqno` cursor to continue the clone. obs.artifact_count_grew = true; // §7.5 L2383-2385 — discard a private cfile without the grant // before decoding it: only the CSIZE framing above must succeed // (a bad frame is a §3.9 fatal); the container is never decoded // and the store stays untouched. if (was_private and privateReceipt(self.private_granted) == .discard) { obs.private_content_discarded = true; return resume_at; // §5.1 — the padding is on the wire even when discarding } if (cc.deltasrc) |deltasrc| { // §7.2-r5/r9/r12 — the delta form genuinely needs the inflated // payload: it is one §4.1 stored container, already outer- // decoded, so it is container-decoded exactly once here. A short // header, a length-field lie, or a corrupt RFC1950 stream cannot // be reconstructed and is a fatal content error (§3.9); OOM // propagates. (The FULL form below is NOT inflated at all — see // there — so this decode lives inside the delta branch only.) const decoded = codec.decode(gpa, payload.bytes, reply_body_cap) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.TruncatedHeader, error.InvalidStream, error.OutputTooLarge => return self.abort(), }; defer gpa.free(decoded); // §7.2-r13/§7.4 — delta form: the container held a stored // compressed delta, so `decoded` is the [FossilDelta] against // DELTASRC (not the artifact). Reconstruct or defer. Pass the // received container (`payload.bytes`) so the immediate path // retains the compact delta form verbatim (§7.2). try self.receiveDelta(gpa, store, obs, cc.hash, deltasrc, decoded, was_private, payload.bytes); return resume_at; } // §7.2-r6/r7/r8 FULL form — the payload is inflated NOWHERE at // receive. Per §7.2 (USIZE is parsed syntactically but not transfer- // layer validated; the payload is the repository's stored compressed // full artifact retransmitted verbatim) the wire bytes are interned // and retained AS-IS via `retain` below. The store reconstructs // plaintext lazily from that container — the read/derivation path // inflates it under a cap (logstore.decodeContainer), so the §3.9 // decompression-bomb guard stays intact THERE — and §7.7 defers // integrity (hashing the RECONSTRUCTED bytes) to the persist commit // hook, from whose pre-storage check a clone-context cfile is // excluded (§7.7 r6). §5.2: a full container that fails to inflate is // therefore NOT a receive-time fatal; content-addressing plus the // capped read path surface corruption at read, not as a clone abort. // Only the §4.1 frame floor is enforced here — a payload too short to // carry the 4-byte length prefix is not a container at all (a framing // error, §3.9 fatal, distinct from the decompression-time corruption // §7.2/§7.7 governs) and would break `retain`'s container.len >= 4 // precondition. if (payload.bytes.len < 4) return self.abort(); const rid = try store.intern(cc.hash); _ = self.phantoms.remove(rid); obs.versioned_content_delivered = true; // §3.7(a) / §8.2 r14 — identical to a full file card obs.artifact_received_this_round = true; // §3.7(e) — a stored cfile delivery this round if (was_private) obs.private_content_delivered = true; // §7.5 L2359 // §7.2/§4.1 — retain the received full container verbatim (the // cfile payload IS a §4.1 container) so a later serve retransmits // it byte-for-byte. Advisory; §7.7 verification of these bytes is // deferred to the persist commit hook (a clone skips the // transfer-layer pre-storage check, §7.7 r6). store.retain(rid, .{ .full = payload.bytes }) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, }; // §7.4 — a full-content cfile may complete a retained delta. self.pending.completeFor(gpa, store, store.hashOf(rid)) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.MalformedDelta, error.ChecksumMismatch => return self.abort(), }; return resume_at; // §7.2-r3/§5.1 — past CSIZE payload bytes and the optional padding LF }, .private => { // §7.5 L2359 — a bare `private` (arity 1, ABNF L4643 // `private-card = "private" LF`) sets the receive-side pending // marker for the next file/cfile card. A defined operator with a // bad visible arg count is that card's own §5.2 arity violation // (L1770-1776): the client aborts the round with no outbound error // card (§5.2 L1809-1813, §3.9). A 7th+ token is already fatal at // classifyCard (surplus → .bad_command → isFatal client), so this // gate covers only the 2..6-token window that reaches here. std.debug.assert(tokens.len >= 1); // operator token always present if (tokens.len != 1) return self.abort(); self.private_pending = true; // r1/r3 — a repeat leaves it set return payload_start; // arg-less card carries no byte payload }, // §7.3 — parse and frame legal unversioned-file cards before // retaining the deliberate unsupported-feature boundary. These cards // never consume `private_pending`, store content, or mutate `obs`. .uvfile => { const card = parseUvfileCard(tokens) catch return self.abort(); if (!validUvHash(card.hash)) return self.abort(); if (card.isDeletion() and card.size != 0) return self.abort(); const payload = consumeUvfilePayload(body, payload_start, card) catch return self.abort(); if (!card.isDeletion() and !card.contentOmitted() and !verifyUvContent(card.hash, payload.bytes)) { return self.abort(); } return ConsumeError.NotImplemented; }, .uvigot => { const card = parseUvigotCard(tokens) catch return self.abort(); if (!validUvHash(card.hash)) return self.abort(); if (std.mem.eql(u8, card.hash, "-") and card.size != 0) return self.abort(); return ConsumeError.NotImplemented; }, .uvgimme => { _ = parseUvgimmeCard(tokens) catch return self.abort(); return ConsumeError.NotImplemented; }, } } /// §7.5 L2363-2364 — consume the pending private marker at file/cfile handler /// entry: read it, clear it unconditionally, return the prior value. The /// consume is before arity/hash validation (L2432-2434 states this for the /// server, and the receive rule is direction-symmetric), so a later-rejected /// card still clears the marker and never binds a subsequent card. fn takePrivateMarker(self: *Client) bool { const was_pending = self.private_pending; self.private_pending = false; std.debug.assert(!self.private_pending); // negative space: always clear after return was_pending; } /// §7.4/§3.3 — receive one delta-form file/cfile payload. `delta_bytes` is /// the [FossilDelta] reconstructing `hash` from `deltasrc` (already §4.1 /// container-decoded for cfile). If the basis is held, reconstruct now, /// verify §7.7 pre-storage integrity for a non-clone file (the reconstruction /// MUST hash to `hash`), store it, and run the §7.4 completion cascade to a /// fixpoint. If the basis is absent, retain the dangling delta, create the /// DELTASRC phantom (§3.3), and drop `hash` from the phantom set so it is not /// re-gimme'd — §3.3 content-received, derived via `pending.hasTarget`. MUST /// NOT reject a dangling delta (§7.4). fn receiveDelta( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, obs: *RoundObservation, hash: []const u8, deltasrc: []const u8, delta_bytes: []const u8, was_private: bool, /// §7.2/§4.1 — the received §4.1 delta container to retain verbatim for /// a compact re-serve, or null when there is no container to preserve (a /// file card carries the raw [FossilDelta] uncompressed, §7.1). Retained /// by BOTH paths: the immediate-reconstruct path (basis held) and the /// dangling path (through the pending queue, persisting the compact /// §7.2 delta form on completion). A container-less file card completes /// to full form. wire_container: ?[]const u8, ) ConsumeError!void { // §7.4 — a delta whose target is already locally verified is consumed // and ignored: MUST NOT downgrade the target or enqueue an edge. `held` // classifies without inflating a wire-backed entry (§3.3 r24). if (store.lookup(hash)) |rid| { if (store.held(rid)) return; } // §7.4 — basis held: reconstruct immediately against its content. §7.7 // pre-storage integrity (the reconstructed bytes MUST hash to HASH for a // non-clone file; a clone file defers to the commit hook, §7.7 r6) is // enforced inside the shared `reconstructDeltaTarget` core. Negative // space: a malformed delta or a reconstruction that hashes to the wrong // name aborts, never landing bytes under a name they do not match. if (store.lookup(deltasrc)) |src_rid| { // §7.2/§7.4 clone fast path — the basis is held and this is a cfile // delta (a §4.1 container on the wire), so the delta is orderable now. // Store it WIRE-BACKED (verbatim container + basis name + reconstructed // USIZE), skipping reconstruction entirely: content() reconstructs // lazily and the clone never memoises the inflated target (peak stays // O(wire)). §7.7 verification is the persist commit hook (§7.7 r6), not // the transfer layer, for a clone. Pull/`file` cards keep the immediate // reconstruct-and-verify path below (small, and its pre-storage §7.7 // check is preserved). A malformed delta whose USIZE header will not // parse aborts the round (§3.9) rather than storing an unusable frame. if (self.operation == .clone and wire_container != null and store.held(src_rid)) { const container = wire_container.?; const target_size = delta.targetSize(delta_bytes) catch return self.abort(); const rid = try store.intern(hash); store.retain(rid, .{ .delta = .{ .src = deltasrc, .container = container, .target_size = target_size } }) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, }; _ = self.phantoms.remove(rid); obs.versioned_content_delivered = true; obs.artifact_received_this_round = true; if (was_private) obs.private_content_delivered = true; // §7.4 cascade — storing HASH may itself be the basis a retained // delta named (a chain C<-A<-B); complete to a fixpoint. self.pending.completeFor(gpa, store, store.hashOf(rid)) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.MalformedDelta, error.ChecksumMismatch => return self.abort(), }; return; } if (store.content(src_rid)) |src_bytes| { const reconstructed = reconstructDeltaTarget(gpa, hash, src_bytes, delta_bytes, self.operation != .clone) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.MalformedDelta, error.IntegrityMismatch => return self.abort(), }; defer gpa.free(reconstructed); const rid = try store.put(hash, reconstructed); _ = self.phantoms.remove(rid); obs.versioned_content_delivered = true; obs.artifact_received_this_round = true; if (was_private) obs.private_content_delivered = true; // §7.2/§4.1 — the basis was held and HASH is now stored: retain // the received delta container verbatim keyed to this rid so a // re-serve retransmits the compact delta form byte-for-byte. The // reconstructed bytes above proved the delta applies, so its // header (delta §3.2) parses; a parse failure only skips the // advisory retention. cfile only — a file card has no container. if (wire_container) |container| retain_blk: { const target_size = delta.targetSize(delta_bytes) catch break :retain_blk; store.retain(rid, .{ .delta = .{ .src = deltasrc, .container = container, .target_size = target_size } }) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, }; } // §7.4 cascade — storing HASH may itself be the basis a retained // delta named (a chain C<-A<-B); complete to a fixpoint. self.pending.completeFor(gpa, store, store.hashOf(rid)) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.MalformedDelta, error.ChecksumMismatch => return self.abort(), }; return; } } // §7.4/§3.3 dangling delta — basis absent. Retain a private copy, delete // HASH's phantom, create the DELTASRC phantom, set content-received. A // cfile carries a §4.1 container we keep VERBATIM (so completion persists // the compact §7.2 delta form — the storage-efficiency win); a `file` // card has none, so only its raw [FossilDelta] is kept and it completes // to full form. const payload: PendingDelta.Payload = if (wire_container) |c| .{ .container = c } else .{ .raw = delta_bytes }; self.pending.retain(gpa, hash, deltasrc, payload) catch |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.QueueFull => return self.abort(), }; // §3.3 — delete HASH's phantom: content-received is now derived from the // retained delta (pending.hasTarget), so HASH is not requested again. if (store.lookup(hash)) |h_rid| _ = self.phantoms.remove(h_rid); // §3.3 item 2 / §7.5 L1258-1259 — create the DELTASRC phantom, private // iff the delta carried a private marker. const src_rid = try store.intern(deltasrc); const gop = try self.phantoms.getOrPut(gpa, src_rid); if (!gop.found_existing) gop.value_ptr.* = .{}; if (was_private) gop.value_ptr.private = true; // §3.3/§3.7(a) — a dangling delta is a versioned-progress event that // creates a phantom; flag it so the round continues to gimme the basis. obs.phantom_created = true; } /// §3.8 r15/r18 — record a fatal protocol error and return it. From /// clone_round0 the fatal event is `clone_fatal`; from exchange it is /// `fatal_error`; both land in `aborted`. fn abort(self: *Client) ConsumeError { std.debug.assert(self.state == .clone_round0 or self.state == .exchange); self.state = transition(self.state, if (self.state == .clone_round0) .clone_fatal else .fatal_error); std.debug.assert(self.state == .aborted); return ConsumeError.FatalProtocolError; } }; /// §3.8 L1089-1091 — the stateless per-request server. The whole round loop /// lives on the `Client`; the server carries only its own static identity and /// answers each request purely from the durable `ArtifactStore` it is given. /// `respond` takes `self: *const Server`, so the compiler proves no field is /// written across requests: every working table is a call-local freed before /// return, and the only mutation is into the caller's store (§3.3 ingest), /// exactly as `consumeReply` mutates it on the client side. pub const Server = struct { /// §10.1 L2319-2323 — the server release/timestamp emitted as /// `pragma server-version` when the client's client-version carried a /// DATE and TIME. Always carries a datetime: a real server knows its own /// build timestamp, so the server-version form is always the full one. version: VersionInfo, /// §6.4 L1988-1993 — the stored 40-hex shared secret used to VERIFY a /// named login, never a plaintext password and never emitted. Null means /// the server accepts unauthenticated push/pull (§A.1 note 1): no login /// verification is performed. credential: ?Credential = null, /// §8.2/§9.1 L1999-2037 — the repository identifiers emitted verbatim in a /// clone reply's `push SERVERCODE PROJECTCODE` card. SERVERCODE is /// informational; PROJECTCODE is how a fresh clone client first learns the /// peer's project code (§8.3/§6.3). Both MUST be non-empty and free of /// Fossil whitespace so the card carries three tokens on the wire — a /// server-config precondition `emitCloneReply` asserts; the default "" is a /// non-clone-serving server that never reaches the clone path. self_servercode: []const u8 = "", project_code: []const u8 = "", /// §8.2 L1384 — the per-reply upper bound on `cfile` cards a clone batch /// emits. Defaults to `clone_cfile_batch_max`; a smaller value pages a /// repository across more batches, exercising the multi-batch clone /// continuation (§8.2 L1413). A server tuning knob, never wire-visible: the /// client resumes each batch from the `clone_seqno NEXT` cursor regardless /// of size (§8.2 L1381 — clients MUST NOT infer count or size from range). clone_batch_max: usize = clone_cfile_batch_max, /// §3.8 — the closed error set `respond` returns. `OutOfMemory` covers the /// growable reply buffer and the store's ingest; `ResponseTooLarge` fences /// a reply that would exceed the §4 L1148-1149 message-body ceiling. An /// authentication failure is deliberately NOT here: it is an in-band /// `error` card reply (§5.2/§6.4), not a returned Zig error. pub const RespondError = error{ OutOfMemory, ResponseTooLarge }; /// §4.1 — a reply body plus the media type that governs its framing. The /// body aliases the caller's `out.items`; `media` is `x_fossil_uncompressed` /// for a §8.1 accepting-clone reply (cfile payloads are already stored /// compressed, so no outer container is applied) and `x_fossil` for every /// other reply (the compressed container). The transport reads `media` to /// frame the body and pick the Content-Type in one decision, so the two can /// never disagree. pub const Reply = struct { body: []const u8, media: MediaType, }; /// §3.2/§3.8 — answer one request body from the durable store, returning /// just the reply bytes. A thin convenience over `respondWithMedia` for the /// callers (the round loop, the sweep, most tests) that reframe a normal /// `application/x-fossil` reply and never serve the §8.1 clone media type. pub fn respond( self: *const Server, gpa: std.mem.Allocator, store: repostate.ArtifactStore, request: []const u8, out: *std.ArrayListUnmanaged(u8), ) RespondError![]const u8 { return (try self.respondWithMedia(gpa, store, request, out)).body; } /// §3.2/§3.8 — answer one request body from the durable store, the reply /// half of the exchange. Sans-IO: bytes in / bytes out, no transport. /// Parses the request cards (§5), verifies a named login (§6.4), ingests /// pushed content and interns announced-but-unheld hashes as phantoms /// (§3.3), then emits the version pragma (§10.1), a file card for each /// satisfiable gimme (§3.3), and — walking the store once — an igot for /// every holding the client neither announced nor requested and a gimme /// for every phantom (§3.2). A malformed card resets the partial output /// and emits a single error card (§5.2). `out` is a reused backing buffer, /// cleared here; the returned `body` is `out.items`. `media` reports whether /// the reply is a §8.1 clone reply (`x_fossil_uncompressed`) or an ordinary /// compressed reply (`x_fossil`). pub fn respondWithMedia( self: *const Server, gpa: std.mem.Allocator, store: repostate.ArtifactStore, request: []const u8, out: *std.ArrayListUnmanaged(u8), ) RespondError!Reply { out.clearRetainingCapacity(); const line_buf = try gpa.alloc(u8, max_line_len); defer gpa.free(line_buf); var scan: RequestScan = .{}; defer scan.deinit(gpa); // §5.2 — a malformed card, or (§6.1/§6.4) a login failure, produces a // single error card over the (still empty) output. Parse fully before // touching the store, so a rejected request ingests nothing. An error // reply carries no cfiles, so it is the ordinary compressed media type. if (try scanRequest(gpa, store, request, &scan)) |fault| return .{ .body = try emitError(gpa, out, line_buf, fault), .media = .x_fossil }; if (authFault(self.credential, &scan, request)) |fault| return .{ .body = try emitError(gpa, out, line_buf, fault), .media = .x_fossil }; // §8 — a clone request is answered by its own paginated cfile reply and // never touches the durable store: clone is receive-only (the client // sends no push/file cards), and §8.9's stateless server holds no clone // state, so ingest and the pull/push walk are both skipped. if (scan.clone_start) |start| { try self.emitCloneReply(gpa, store, &scan, out, line_buf, start); if (out.items.len > reply_body_cap) return error.ResponseTooLarge; // §8.1 L1365 — a version-3 clone reply is served as // application/x-fossil-uncompressed; §4.1 L829-831 — no outer // container, because the cfile payloads are already stored compressed. return .{ .body = out.items, .media = .x_fossil_uncompressed }; } try ingest(store, &scan); try self.emitReply(gpa, store, &scan, out, line_buf); if (out.items.len > reply_body_cap) return error.ResponseTooLarge; return .{ .body = out.items, .media = .x_fossil }; } /// §10.1/§3.2/§3.3 — emit the reply cards over the ingested store: the /// server-version pragma, a file card per satisfiable gimme, then one store /// walk announcing holdings the client lacks and requesting the server's /// phantoms. fn emitReply( self: *const Server, gpa: std.mem.Allocator, store: repostate.ArtifactStore, scan: *const RequestScan, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, ) RespondError!void { // §10.1 L2319-2323 — reply with server-version exactly when the // client-version carried DATE and TIME. if (scan.client_version) |cv| { if (serverEmitsServerVersion(cv)) { std.debug.assert(self.version.datetime != null); // server config precondition const p = writeServerVersion(self.version, line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, p); } } // §3.3 L848-852 — satisfy each gimme the server holds with a file card, // in request order; a hash it lacks is silently skipped (§3.3 L855-856). for (scan.gimmes.items) |hash| { if (store.lookup(hash)) |rid| { if (store.content(rid)) |bytes| { writeFile(gpa, out, store.hashOf(rid), bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoSpaceLeft => return error.ResponseTooLarge, }; } } } // §3.2 L722-723/L735-736 — one bounded store walk. A held artifact the // client neither announced (igot) nor requested (gimme, delivered above) // is advertised with igot; a phantom (content absent) is requested with // gimme. Rids are dense in 1..=count (repostate contract). const n: repostate.Rid = store.count(); var rid: repostate.Rid = 1; while (rid <= n) : (rid += 1) { const hash = store.hashOf(rid); if (store.content(rid) != null) { if (!containsHash(scan.igots.items, hash) and !containsHash(scan.gimmes.items, hash)) { const card = writeIgot(hash, false, line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, card); } } else { const card = writeGimme(hash, line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, card); } } } /// §8.2 L1878-1443 — answer one `clone 3 SEQNO` request: a bounded batch of /// the store's artifacts as `cfile` cards in RID order from SEQNO, then the /// `clone_seqno NEXT` cursor and the `push SERVERCODE PROJECTCODE` card. The /// server is stateless (§3.8/§8.9): the request's SEQNO IS the cursor, so no /// clone progress is remembered between requests. `start` is a blob-table RID /// (§8.2 L1889); the store's RIDs are dense in 1..=count (repostate /// contract), so "silently skip gaps" (L1381) reduces to skipping phantoms /// (content absent) — never sent, they carry no stored bytes to retransmit. fn emitCloneReply( self: *const Server, gpa: std.mem.Allocator, store: repostate.ArtifactStore, scan: *const RequestScan, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, start: u64, ) RespondError!void { std.debug.assert(start >= 1); // §8.1 — SEQNO 0 was rejected before this std.debug.assert(self.clone_batch_max >= 1); // a zero-size batch never advances the cursor (§8.2 L1405 liveness) // §8.2 L1386-1387 — server-version precedes the clone sequence, on the // same §10.1 gate `emitReply` uses (emitted iff the client-version // carried DATE and TIME). The clone client sends an undated // client-version, so this is normally silent — §8.2 fixes the ordering // among emitted cards, §10.1 fixes whether server-version is emitted. if (scan.client_version) |cv| { if (serverEmitsServerVersion(cv)) { std.debug.assert(self.version.datetime != null); // server config precondition const p = writeServerVersion(self.version, line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, p); } } // §8.2 L1384-1385 — zero or more `cfile` cards in RID order from `start`, // a batch of at most `clone_cfile_batch_max` artifacts. Both the walk // (bounded by `count()`) and the batch (bounded by the cap) are explicit // upper bounds; the cursor `rid` after the loop is the first RID not yet // sent, or count+1 when the store is exhausted. const n: usize = store.count(); var rid: usize = start; var emitted: usize = 0; while (rid <= n) : (rid += 1) { if (emitted >= self.clone_batch_max) break; // batch full: resume at `rid` const r: repostate.Rid = @intCast(rid); if (store.wire(r)) |wf| { // §4.1/§7.2 — serve the stored container verbatim, no // re-compression. Form-aware: full vs delta. if (wf.delta) |d| { // §7.2 delta form — USIZE is the RECONSTRUCTED artifact // size (delta §3.2), never the container's 4-byte prefix // (the raw delta length, §7.2 L1142-1144). if (std.debug.runtime_safety) { // Form-aware assert: the declared reconstructed size // equals the size content() actually reconstructs. if (store.content(r)) |plain| { std.debug.assert(d.target_size == plain.len); } // D3 structural phantom safety — a served delta's basis // is in-store, at an earlier RID, and held, so it was // (or is) emitted earlier in the same clone sequence and // never strands the receiver past §8.2 cleanup. (§7.5 // L1260-1262 privacy: zit's server holds no private // content yet; this assert reserves the check.) const src_rid = store.lookup(d.src_hash).?; std.debug.assert(src_rid < r); std.debug.assert(store.content(src_rid) != null or store.wire(src_rid) != null); } writeCfileVerbatimDelta(gpa, out, store.hashOf(r), d.src_hash, d.target_size, wf.container) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoSpaceLeft => return error.ResponseTooLarge, }; } else { // §7.2 full form — USIZE is the container's 4-byte prefix. // TigerStyle: prove it equals the uncompressed length. if (std.debug.runtime_safety) { if (store.content(r)) |plain| { std.debug.assert(std.mem.readInt(u32, wf.container[0..4], .big) == plain.len); } } writeCfileVerbatim(gpa, out, store.hashOf(r), wf.container) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoSpaceLeft => return error.ResponseTooLarge, }; } emitted += 1; } else if (store.content(r)) |bytes| { // Fallback: a raw or non-wire store — compress on the fly (§7.2). writeCfile(gpa, out, store.hashOf(r), bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.InputTooLarge, error.NoSpaceLeft => return error.ResponseTooLarge, }; emitted += 1; } } // §8.2 L1387 — NEXT is the next RID to fetch, or 0 at exhaustion. A // positive NEXT is only produced when the batch filled (advancing the // cursor past `start`), so the §8.2 L1405 liveness invariant holds: a // positive NEXT always ends a round that advanced the sequence. const next: u64 = if (rid > n) 0 else rid; std.debug.assert(next == 0 or (next > start and next <= n)); std.debug.assert((next > 0) == (emitted >= self.clone_batch_max and rid <= n)); const seqcard = writeCloneSeqno(next, line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, seqcard); // §8.2 L1386, §8.5 L1478-1480 — `push SERVERCODE PROJECTCODE` closes the // clone sequence; the client learns the project code from it (§8.3/§6.3). // Non-empty codes are a server-config precondition (§9.1: an empty field // would re-tokenize the card to two tokens and abort the peer). std.debug.assert(self.self_servercode.len > 0 and self.project_code.len > 0); const pushcard = writePushPullCard(.push, self.self_servercode, self.project_code, line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, pushcard); } }; /// §8.2 L1384 — the default per-reply upper bound on `cfile` cards a clone /// batch emits (a server may override it via `Server.clone_batch_max`). /// Combined with the §4 L1148-1149 `reply_body_cap` ceiling `respond` /// enforces, it fully bounds a clone reply. The client resumes the remainder /// with `clone 3 NEXT` (§8.2 L1413), so a repository of any size clones across /// a bounded number of rounds rather than one unbounded reply. const clone_cfile_batch_max: usize = 1024; /// §5.2/§6.4 — the three in-band reply faults `respond` can raise: an unknown /// operator (`error bad command: LINE`), a malformed content card (`error /// malformed CARD line`), and an authentication failure (`error TEXT`). Each is /// a wire reply, never a returned Zig error, so the RespondError set stays /// {OutOfMemory, ResponseTooLarge}. const RespondFault = union(enum) { bad_command: []const u8, // the offending line (borrows the request) malformed: Operator, // a content-bearing card whose arity failed unauthorized, // §6.1 duplicate login or §6.4 signature mismatch invalid_clone_seqno, // §8.1 L1369-1376 — `clone VERSION SEQNO` with VERSION≥2 and digit-only SEQNO≤0 }; /// §6.4 — the impl-chosen auth-failure TEXT. The RFC pins the reset-and-emit /// behaviour (§6.1 L1932-1933, §6.4 L1990-1993) but not this string; the error /// operator and reset are normative, the wording is ours. const respond_unauthorized_text = "not authorized"; /// §7.1/§7.4 — one pushed file card collected during the request scan, ready to /// store. `hash` borrows the request bytes. `payload` is the artifact content to /// store: for a full-content card it borrows the request bytes; for a delta-form /// card it is the §7.4-reconstructed content, a scan-owned allocation pointed to /// by `owned` and freed in `deinit`. Both borrows are valid for the duration of /// `respond`. const RequestFile = struct { hash: []const u8, payload: []const u8, owned: ?[]u8 = null, // non-null → a reconstructed delta target this scan allocated }; /// §3.2 — the call-local tables `respond` derives its reply from. All borrow /// the request bytes or own only their own list storage, freed before return; /// no fragment outlives the call, so the server keeps no cross-request state. const RequestScan = struct { igots: std.ArrayListUnmanaged([]const u8) = .empty, gimmes: std.ArrayListUnmanaged([]const u8) = .empty, files: std.ArrayListUnmanaged(RequestFile) = .empty, client_version: ?VersionInfo = null, login_count: usize = 0, login: ?LoginCard = null, login_body_start: usize = 0, // §6.2 — nonce region start: the byte after the login LF clone_start: ?u64 = null, // §8.2 — an accepting `clone 3 SEQNO`'s SEQNO (a blob-table RID ≥ 1) fn deinit(self: *RequestScan, gpa: std.mem.Allocator) void { self.igots.deinit(gpa); self.gimmes.deinit(gpa); // §7.4 — free every reconstructed delta target this scan allocated; a // full-content file's payload borrows the request and owns nothing. for (self.files.items) |f| if (f.owned) |buf| gpa.free(buf); self.files.deinit(gpa); self.* = undefined; } }; /// §3.2 — one scanned card's outcome: advance the cursor to the given offset /// (past any byte-counted payload, §5.1), or abort with a `RespondFault`. const ScanOutcome = union(enum) { advance: usize, fault: RespondFault, }; /// §5.1/§5.2 — walk the request card stream once, collecting the reply-relevant /// cards into `scan`. Returns the first fatal card as a `RespondFault` (unknown /// operator or malformed content) or null when the whole body parses. Ingests /// nothing: a fault must leave the store untouched, so storage waits for pass 2. fn scanRequest(gpa: std.mem.Allocator, store: repostate.ArtifactStore, request: []const u8, scan: *RequestScan) Server.RespondError!?RespondFault { var cursor: usize = 0; while (cursor < request.len) { const line = readLine(request, cursor) catch return RespondFault{ .bad_command = request[cursor..@min(request.len, cursor + max_line_len)] }; var next = line.next; if (!isCommentLine(line.bytes)) { const cc = classifyCard(line.bytes); if (cc.operator == null) return RespondFault{ .bad_command = line.bytes }; if (isFatal(cc.disposition, .server)) return RespondFault{ .malformed = cc.operator.? }; switch (try scanCard(gpa, store, request, scan, cc, line)) { .fault => |fault| return fault, .advance => |offset| next = offset, } } std.debug.assert(next > cursor); // §5.1 — the cursor strictly advances cursor = next; } return null; } /// §3.2 — collect one non-fatal-classified card into `scan`, returning the /// cursor offset to resume at, or a per-card `RespondFault` for an arity/format /// failure the card's own handler detects (§5.2). Only the A0 request /// vocabulary is accepted; any other defined operator is rejected like an /// unknown one, aborting the exchange. fn scanCard( gpa: std.mem.Allocator, store: repostate.ArtifactStore, request: []const u8, scan: *RequestScan, cc: CardClass, line: Line, ) Server.RespondError!ScanOutcome { switch (cc.operator.?) { .pragma => { // §10.1 — record the client-version (VERSION [DATE TIME]); every // other pragma name is tolerated and ignored (§3.5). if (cc.tokens.len >= 2 and std.mem.eql(u8, cc.tokens.items[1], "client-version")) { scan.client_version = parseVersionPragma(cc.tokens) catch null; } }, .push, .pull => { // §9.1 — arity-validate only; project-code persistence is a later // batch. Too few tokens is a simple-card failure (§5.2 bad command). if (parsePushPull(cc.tokens) == null) return .{ .fault = .{ .bad_command = line.bytes } }; }, .login => { // §6.1 — a malformed login is a simple-card failure; a well-formed // one is counted (duplicate detection is §6.4's job in authFault). const card = parseLoginCard(cc.tokens) orelse return .{ .fault = .{ .bad_command = line.bytes } }; scan.login_count += 1; if (scan.login == null) { scan.login = card; scan.login_body_start = line.next; // §6.2 — bytes after the login LF } }, .igot => { const card = parseIgotCard(cc.tokens) catch return .{ .fault = .{ .bad_command = line.bytes } }; try scan.igots.append(gpa, card.hash); }, .gimme => { const card = parseGimmeCard(cc.tokens) catch return .{ .fault = .{ .bad_command = line.bytes } }; try scan.gimmes.append(gpa, card.hash); }, .file => { // §7.1 — full form (file HASH SIZE) or delta form // (file HASH DELTASRC SIZE); arity alone disambiguates. A bad arity // or non-numeric SIZE is `error malformed file line`. SIZE frames the // payload identically in both forms. const fc = parseFileCard(cc.tokens) catch return .{ .fault = .{ .malformed = .file } }; const payload = consumeContentPayload(request, line.next, fc.size) catch return .{ .fault = .{ .malformed = .file } }; if (fc.deltasrc) |deltasrc| { // §7.1-r5/§7.4 — delta form: the payload is a [FossilDelta] // against DELTASRC. The stateless server (§3.8) reconstructs // immediately against a HELD basis and §7.7-verifies before store; // a basis-absent, malformed, or wrong-hash delta is rejected as a // malformed file card (store untouched — the fault stops the scan // before pass-2 ingest), never silently dropped. Reconstructing in // the read-only scan pass keeps `respond` stateless: same request + // same store → same reconstructed bytes. const reconstructed = reconstructPushedDelta(gpa, store, fc.hash, deltasrc, payload.bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.BasisAbsent, error.MalformedDelta, error.IntegrityMismatch => return .{ .fault = .{ .malformed = .file } }, }; errdefer gpa.free(reconstructed); // a failed append must not leak the reconstruction try scan.files.append(gpa, .{ .hash = fc.hash, .payload = reconstructed, .owned = reconstructed }); return .{ .advance = payload.next }; // §5.1 — resume past the payload } try scan.files.append(gpa, .{ .hash = fc.hash, .payload = payload.bytes }); return .{ .advance = payload.next }; // §5.1 — resume past the payload }, .clone => { // §8.1/§8.2 — record an accepting `clone 3 SEQNO`'s SEQNO as the // reply cursor; a VERSION≥2, digit-only SEQNO≤0 is the fatal // invalid-sequence error (L1369-1376); any other shape (wrong arity, // noninteger VERSION, non-digit SEQNO, out-of-scope VERSION) is a // plain §5.2 bad command, per L1374-1376's explicit carve-out. switch (classifyCloneRequest(cc.tokens)) { .accept => |seqno| scan.clone_start = seqno, .invalid_seqno => return .{ .fault = .invalid_clone_seqno }, .malformed => return .{ .fault = .{ .bad_command = line.bytes } }, } }, .reqconfig => { // §9.3/§10.3 r20-r21 — a configuration request. NAME MUST begin // with '/' (r20 L2369-2370); a NAME-less or non-slash form is a // §5.2 malformed card. zit's anonymous server grants Read (§A.1 // note 1), so the disposition is `.reply` — but zit models no // config group (all table groups are out of scope, §2/§9.3), so // the requested group's mask is zero: the server emits NO config // cards and NO error (§9.3 L1772-1774). Accepting the card here is // exactly what turns the former `error bad command: reqconfig …` // into a clean, config-less reply, so a Fossil clone completes // past the §8.4 configuration phase. (The project code itself // travels via the clone reply's `push` card, §8.2/§8.3 — not // through config; the RFC defines no project-code config setting.) const name = parseReqconfigName(cc.tokens) orelse return .{ .fault = .{ .bad_command = line.bytes } }; if (!configNameHasSlashPrefix(name)) return .{ .fault = .{ .bad_command = line.bytes } }; // Nothing to record: no modelled group means no config emission. }, // §3.9 — any other defined operator is outside the A0 request // vocabulary; strict-accept rejects it and aborts. else => return .{ .fault = .{ .bad_command = line.bytes } }, } return .{ .advance = line.next }; } /// §6.1/§6.4 — the authentication verdict. A duplicate login (§6.1 L1927-1931) /// or a signature that fails to reconstruct (§6.4 L1988-1993) is `.unauthorized`; /// an anonymous request, or any request against a server holding no credential, /// authenticates trivially (§A.1 note 1). The nonce covers exactly the request /// bytes after the login LF — the same region `buildRequest` signed. fn authFault(credential: ?Credential, scan: *const RequestScan, request: []const u8) ?RespondFault { if (duplicateLogin(false, scan.login_count)) return .unauthorized; const card = scan.login orelse return null; const cred = credential orelse return null; if (!std.mem.eql(u8, card.user, cred.user)) return .unauthorized; std.debug.assert(scan.login_body_start <= request.len); const nonce = computeNonce(request[scan.login_body_start..]); const signature = computeSignature(&nonce, &cred.secret_hex); if (!std.mem.eql(u8, &signature, card.signature)) return .unauthorized; return null; } /// §3.3 — apply the request's store effects: store each pushed file (§3.3 L806, /// dephantomizing) and intern each announced hash (idempotent — a held or /// already-phantom hash is unchanged, an unheld one becomes a phantom the reply /// will gimme, §3.3 L811/§3.2 L735-736). fn ingest(store: repostate.ArtifactStore, scan: *const RequestScan) Server.RespondError!void { for (scan.files.items) |f| _ = try store.put(f.hash, f.payload); for (scan.igots.items) |hash| _ = try store.intern(hash); } /// §5.2/§6.4 — reset the partial output and emit the single error card for /// `fault`: `error bad command: LINE` and `error malformed CARD line` are the /// literal framings of §5.2/Appendix C; an auth failure is a fossilize-encoded /// free-text error card (§11.1 r2). fn emitError( gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, fault: RespondFault, ) Server.RespondError![]const u8 { out.clearRetainingCapacity(); // §5.2 L1806-1808 / §6.1 L1932 — reset partial output const err_prefix = "error "; const card: []const u8 = switch (fault) { .bad_command => |line| blk: { if (err_prefix.len + "bad command: ".len + line.len > max_line_len) return error.ResponseTooLarge; @memcpy(line_buf[0..err_prefix.len], err_prefix); const bc = writeBadCommand(line, line_buf[err_prefix.len..]); break :blk line_buf[0 .. err_prefix.len + bc.len]; }, .malformed => |op| blk: { @memcpy(line_buf[0..err_prefix.len], err_prefix); const mc = writeMalformedCard(op, line_buf[err_prefix.len..]); break :blk line_buf[0 .. err_prefix.len + mc.len]; }, .unauthorized => writeSignalCard(.err, respond_unauthorized_text, line_buf), // §8.1 L1373 — the fatal `error invalid clone sequence number` over the // (already cleared) output: the request transaction is rolled back (no // ingest ran) and no later request card is parsed (the scan stopped here). .invalid_clone_seqno => writeSignalCard(.err, clone_invalid_seqno_text, line_buf), }; try appendCard(gpa, out, card); return out.items; } /// §8.1 L1373 — the fixed free text of the invalid-clone-sequence error card. /// The RFC pins the `error` operator and the reset-and-roll-back behaviour; this /// exact wording is the one Fossil emits and the interoperable string. const clone_invalid_seqno_text = "invalid clone sequence number"; /// Linear membership over a small collected hash list — the request card count /// bounds it, so no set structure is warranted. Compares by bytes, so a store /// hash slice matches a request hash slice with the same content. fn containsHash(list: []const []const u8, hash: []const u8) bool { for (list) |h| if (std.mem.eql(u8, h, hash)) return true; return false; } /// §10.1 r6/r11 — the two version-pragma names `consumeReply` records the peer /// version from; every other pragma name is tolerated and ignored (§3.5). fn isVersionPragmaName(name: []const u8) bool { return std.mem.eql(u8, name, "server-version") or std.mem.eql(u8, name, "client-version"); } // --------------------------------------------------------------------------- // Section 5 — Card Format // --------------------------------------------------------------------------- /// §5.2 — card/comment line bound, measured from the first line byte /// through and including the terminating LF, excluding any following /// byte-counted payload. pub const max_line_len: usize = 65535; /// §5.2 — current Fossil implementations expose at most the first six /// parsed tokens to the per-card handler; later tokens are surplus. pub const max_tokens: usize = 6; /// §5.2.1, §5-r20 — the complete card operator catalog. `parseOperator` /// resolves a token against this set by exact field-name match, so the /// catalog itself is the lexical gate for §5-r6 (lowercase-plus- /// underscore operator tokens): anything else simply has no field. pub const Operator = enum { cookie, login, push, pull, clone, file, cfile, uvfile, private, igot, uvigot, gimme, uvgimme, reqconfig, config, clone_seqno, pragma, @"error", message, }; comptime { // §5-r20 — Section 5.2.1 lists 4, the remaining-operators sentence // lists 15; 4 + 15 = 19. std.debug.assert(@typeInfo(Operator).@"enum".fields.len == 19); } /// Which end of the exchange is classifying the card (§5-r17: the same /// disposition is fatal for the client and non-fatal for the server). pub const Side = enum { client, server }; /// §5.2 outcome of classifying one card line. pub const Disposition = enum { dispatch, unknown_operator, malformed_content, bad_command }; /// §5.1 — one LF-delimited line. `bytes` excludes the LF; `next` is the /// body offset immediately after it, where a following byte-counted /// payload (if any) begins. pub const Line = struct { bytes: []const u8, next: usize, }; /// §5-r11 — the sole line-framing failure: no LF within the bound. pub const LineError = error{MissingTerminator}; /// §5.2 tokenization result: up to `max_tokens` slots into the source /// line, plus a flag for tokens beyond that window (§5-r14). pub const Tokens = struct { items: [max_tokens][]const u8 = undefined, len: usize = 0, surplus: bool = false, }; /// §5.2 classification of a card line: its tokens, the resolved /// operator (null when unrecognized), and the disposition that follows. pub const CardClass = struct { tokens: Tokens, operator: ?Operator, disposition: Disposition, }; /// §5.3, App. A.4, App. B.3 — a parsed `# timestamp DATETIME errors N` /// comment subform. pub const TimestampComment = struct { datetime: []const u8, errors: u64, }; /// §5.1, §5-r10/r11 — find the next LF-terminated line starting at /// `start`. The LF must fall within `max_line_len` bytes of `start` /// (bound counted through-and-including the LF); a following byte- /// counted payload is never scanned (§5-r3) — the caller advances past /// it using `next + declared_count`. pub fn readLine(body: []const u8, start: usize) LineError!Line { std.debug.assert(start <= body.len); const window_end = @min(body.len, start + max_line_len); var i: usize = start; while (i < window_end) : (i += 1) { if (body[i] == '\n') { const line: Line = .{ .bytes = body[start..i], .next = i + 1 }; std.debug.assert(line.bytes.len <= max_line_len - 1); std.debug.assert(line.next <= body.len); return line; } } return LineError.MissingTerminator; } /// §5.2, §5-r7 — Fossil whitespace: SP, HTAB, VT, FF, CR. LF is /// deliberately excluded: it terminates the card line and is never a /// token separator. pub fn isFossilWhitespace(b: u8) bool { return switch (b) { ' ', '\t', 0x0b, 0x0c, '\r' => true, else => false, }; } /// §5.2, §5-r4/r5/r7/r8/r9/r14 — tokenize a card line (LF already /// stripped by `readLine`) on runs of Fossil whitespace. The canonical /// single-space wire form and the compatibility multi-run extension /// produce identical tokens. A token past the sixth sets `surplus` and /// is not stored (Fossil's fixed six-slot handler window). pub fn tokenizeCard(line: []const u8) Tokens { std.debug.assert(line.len <= max_line_len - 1); std.debug.assert(std.mem.indexOfScalar(u8, line, '\n') == null); var result: Tokens = .{}; var i: usize = 0; while (i < line.len) { if (isFossilWhitespace(line[i])) { i += 1; continue; } const start = i; while (i < line.len and !isFossilWhitespace(line[i])) : (i += 1) {} const token = line[start..i]; if (result.len < max_tokens) { result.items[result.len] = token; result.len += 1; } else { result.surplus = true; } } std.debug.assert(result.len <= max_tokens); std.debug.assert(!result.surplus or result.len == max_tokens); return result; } /// §5.2, §5-r6/r12 — resolve an operator token against the catalog. /// `std.meta.stringToEnum` only matches an exact field-name string, so /// an uppercase letter, a stray digit, or a misplaced underscore falls /// through to null (unknown operator) without a separate lexical check. pub fn parseOperator(token: []const u8) ?Operator { return std.meta.stringToEnum(Operator, token); } /// §5-r16 — the three payload-bearing card types, whose arity failure /// aborts with `malformed CARD line` rather than `bad command:`. pub fn isContentBearing(op: Operator) bool { return switch (op) { .file, .cfile, .uvfile => true, else => false, }; } /// §5.2 dispatch gate: tokenize once, then apply §5-r12 (unknown first /// token) and §5-r14/r15/r16/r17 (surplus-token disposition). A `uvfile` /// deliberately dispatches with its six captured slots even when more visible /// tokens follow; every other operator retains strict surplus rejection. /// Per-operator arity within the captured window is that card's own handler /// (§5-r13), out of scope here. pub fn classifyCard(line: []const u8) CardClass { const tokens = tokenizeCard(line); const result: CardClass = blk: { if (tokens.len == 0) { break :blk .{ .tokens = tokens, .operator = null, .disposition = .unknown_operator }; } const operator = parseOperator(tokens.items[0]); if (operator == null) { break :blk .{ .tokens = tokens, .operator = null, .disposition = .unknown_operator }; } if (tokens.surplus and operator.? != .uvfile) { const disposition: Disposition = if (isContentBearing(operator.?)) .malformed_content else .bad_command; break :blk .{ .tokens = tokens, .operator = operator, .disposition = disposition }; } break :blk .{ .tokens = tokens, .operator = operator, .disposition = .dispatch }; }; // Positive/negative space pairing on the disposition: dispatch carries a // resolved operator and either no surplus, or the `uvfile` six-slot // compatibility exception; every other outcome fails one of those gates. std.debug.assert((result.disposition == .dispatch) == (result.operator != null and (!result.tokens.surplus or result.operator.? == .uvfile))); std.debug.assert((result.disposition == .unknown_operator) == (result.operator == null)); return result; } /// §5-r12/r16/r17, Table 3 — unknown operator and malformed content are /// fatal on either side. Bad command is fatal only when the client /// detects it; on the server it is non-fatal (§5-r17). pub fn isFatal(d: Disposition, side: Side) bool { return switch (d) { .unknown_operator, .malformed_content => true, .bad_command => side == .client, .dispatch => false, }; } /// §5-r17/r18, App. C — the sole unrecognized-card wire text: exactly /// `"bad command: "` followed by the offending line. There is no /// "Unknown message" text anywhere in the protocol. pub fn writeBadCommand(line: []const u8, out: []u8) []u8 { const prefix = "bad command: "; std.debug.assert(out.len >= prefix.len + line.len); @memcpy(out[0..prefix.len], prefix); @memcpy(out[prefix.len .. prefix.len + line.len], line); return out[0 .. prefix.len + line.len]; } /// §5-r16, App. C — `malformed CARD line`, where CARD is the operator /// name (the same uppercase-metavariable convention as LINE in /// §5-r17), e.g. `malformed file line`. pub fn writeMalformedCard(op: Operator, out: []u8) []u8 { std.debug.assert(isContentBearing(op)); const name = @tagName(op); const prefix = "malformed "; const suffix = " line"; const needed = prefix.len + name.len + suffix.len; std.debug.assert(out.len >= needed); var i: usize = 0; @memcpy(out[i .. i + prefix.len], prefix); i += prefix.len; @memcpy(out[i .. i + name.len], name); i += name.len; @memcpy(out[i .. i + suffix.len], suffix); i += suffix.len; return out[0..i]; } /// §5.3, §5-r21 — a comment line is any line whose first byte is /// ASCII `#` (0x23). pub fn isCommentLine(line: []const u8) bool { return line.len > 0 and line[0] == '#'; } /// §5.3, §5-r23/r24/r25, App. A.4, App. B.3 — parse the /// `# timestamp DATETIME errors N` subform out of a comment line. Any /// other shape (nonce comment, `# sending-clusters`, a non-numeric N) /// yields null: those lines are not semantically interpreted (§5-r25). pub fn parseTimestampComment(line: []const u8) ?TimestampComment { std.debug.assert(isCommentLine(line)); const tokens = tokenizeCard(line); if (tokens.len != 5 or tokens.surplus) return null; if (!std.mem.eql(u8, tokens.items[0], "#")) return null; if (!std.mem.eql(u8, tokens.items[1], "timestamp")) return null; if (!std.mem.eql(u8, tokens.items[3], "errors")) return null; const n = std.fmt.parseInt(u64, tokens.items[4], 10) catch return null; return .{ .datetime = tokens.items[2], .errors = n }; } // --------------------------------------------------------------------------- // Section 10 — Pragmas // --------------------------------------------------------------------------- /// §10 L2287-2291 — the complete pragma name catalog: nine client-to- /// server names followed by five server-to-client names, in the RFC's /// own listing order. pub const PragmaName = enum { @"send-private", @"send-catalog", @"client-version", @"uv-hash", @"ci-lock", @"ci-unlock", @"client-url", @"req-links", @"req-clusters", @"server-version", @"uv-push-ok", @"uv-pull-only", @"ci-lock-fail", @"avoid-delta-manifests", }; comptime { // §10 L2287 — "Fourteen pragma names are defined." std.debug.assert(@typeInfo(PragmaName).@"enum".fields.len == 14); } /// §10 L2287-2291 — which side originates a pragma. pub const PragmaDirection = enum { client_to_server, server_to_client }; /// §10 L2287-2291 — direction table, split exactly as the RFC lists it. pub fn pragmaDirection(name: PragmaName) PragmaDirection { return switch (name) { .@"send-private", .@"send-catalog", .@"client-version", .@"uv-hash", .@"ci-lock", .@"ci-unlock", .@"client-url", .@"req-links", .@"req-clusters", => .client_to_server, .@"server-version", .@"uv-push-ok", .@"uv-pull-only", .@"ci-lock-fail", .@"avoid-delta-manifests", => .server_to_client, }; } comptime { // §10 L2287-2291 — "Nine are client-to-server ... Five are // server-to-client." var c2s: usize = 0; var s2c: usize = 0; for (@typeInfo(PragmaName).@"enum".fields) |f| { switch (pragmaDirection(@enumFromInt(f.value))) { .client_to_server => c2s += 1, .server_to_client => s2c += 1, } } std.debug.assert(c2s == 9 and s2c == 5); } /// §10 r2/r3/r5 L2281-2283 — resolve a pragma name token. Unrecognised /// tokens fall through to null via the same exact-field-name match /// `parseOperator` uses for card operators; this IS the forward- /// compatibility mechanism, not a special case of it. pub fn parsePragmaName(token: []const u8) ?PragmaName { return std.meta.stringToEnum(PragmaName, token); } /// §10 L2279 result of classifying a `pragma` card: the resolved name /// (null when the receiver does not recognise it, §10 r2) plus the raw /// tokens for the caller's own per-pragma argument parsing. pub const PragmaClass = struct { name: ?PragmaName, tokens: Tokens }; /// §10 r1 L2279 — a pragma card is `pragma NAME [ARGUMENTS]`. Runs only /// after `classifyCard` has already dispatched the line as `.pragma` /// with no surplus tokens (§5); this function does not re-derive that. pub fn classifyPragma(tokens: Tokens) PragmaClass { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .pragma); std.debug.assert(!tokens.surplus); const name: ?PragmaName = if (tokens.len >= 2) parsePragmaName(tokens.items[1]) else null; return .{ .name = name, .tokens = tokens }; } /// §10.1 L2310-2312 — DATE is YYYYMMDD, TIME is HHMMSS; a pragma carries /// both together or neither (L2312, L2320-2321), so this is one /// both-or-neither optional on `VersionInfo`/`Peer`, never two fields. pub const DateTime = struct { date: u32, time: u32 }; /// §10.1 — one parsed wire shape shared by both version pragmas /// (client-version and server-version carry the same VERSION [DATE /// TIME] grammar, L2306/L2319-2320). pub const VersionInfo = struct { version: u32, datetime: ?DateTime = null }; /// §3.1 L353-357 endpoint state entity ("peer version" / "peer date") /// kept deliberately distinct from `VersionInfo`: `VersionInfo` is the /// parsed wire payload of one pragma, `Peer` is the carried per-session /// record with a role-specific pre-pragma default (server seeds VERSION /// 0 per r10 L2317-2319; client seeds its own release integer per r14 /// L2326-2328). Same fields, different lifecycle and default — merging /// them would erase which constructor supplied the seed. pub const Peer = struct { version: u32, datetime: ?DateTime = null, /// §10.1 r10 L2317-2319 — server's peer record before any /// client-version pragma has been received. pub fn serverInit() Peer { return .{ .version = 0 }; } /// §10.1 r14 L2326-2328 — client's peer record before any /// server-version pragma has been received: its own release /// integer. pub fn clientInit(own_version: u32) Peer { return .{ .version = own_version }; } }; /// §10.1 r9 L2316-2317 (server records client) and r13 L2325-2326 /// (client records server) — the recording action is byte-for-byte /// identical on both sides, so one function serves both; only the /// pre-record default differs, and that lives in the two `Peer` /// constructors above, not here. pub fn recordPeerVersion(p: *Peer, v: VersionInfo) void { p.version = v.version; p.datetime = v.datetime; std.debug.assert(p.version == v.version and std.meta.eql(p.datetime, v.datetime)); } /// §10.1 r6 L2303-2304 — client-version is mandatory on every request; /// a missing or non-decimal VERSION is a real parse failure, not a /// modelled-away optional. pub const PragmaError = error{Malformed}; /// §10.1 r11 L2321 — "at least five tokens total" (pragma, NAME, /// VERSION, DATE, TIME) is the sole gate for DATE/TIME presence. pub const min_datetime_tokens: usize = 5; /// §10.1 r7 L2308-2310 — VERSION = MAJOR*10000 + MINOR*100 + PATCH. /// MINOR and PATCH are two-digit fields; MAJOR is bounded so the sum /// cannot overflow the u32 result. pub fn encodeVersion(major: u32, minor: u32, patch: u32) u32 { std.debug.assert(minor < 100 and patch < 100); const max_major = (std.math.maxInt(u32) - 9999) / 10000; std.debug.assert(major <= max_major); return major * 10000 + minor * 100 + patch; } /// §10.1 r6/r8/r11 L2303-2323 — parse `pragma {client,server}-version /// VERSION [DATE TIME]`. VERSION is `items[2]` and mandatory (r6); /// DATE/TIME are `items[3]`/`items[4]`, present iff the card has at /// least `min_datetime_tokens` tokens (r11). pub fn parseVersionPragma(tokens: Tokens) PragmaError!VersionInfo { if (tokens.len < 3) return PragmaError.Malformed; const version = std.fmt.parseInt(u32, tokens.items[2], 10) catch return PragmaError.Malformed; var datetime: ?DateTime = null; if (tokens.len >= min_datetime_tokens) { const date = std.fmt.parseInt(u32, tokens.items[3], 10) catch return PragmaError.Malformed; const time = std.fmt.parseInt(u32, tokens.items[4], 10) catch return PragmaError.Malformed; datetime = .{ .date = date, .time = time }; } const result: VersionInfo = .{ .version = version, .datetime = datetime }; std.debug.assert((result.datetime == null) == (tokens.len < min_datetime_tokens)); return result; } /// §10.1 r11/r12 L2319-2323 — the server replies with server-version /// exactly when the received client-version pragma carried DATE and /// TIME. pub fn serverEmitsServerVersion(v: VersionInfo) bool { return v.datetime != null; } /// §10.1 r6/r8 L2303-2313 — write `pragma client-version VERSION [DATE /// TIME]`: full form when `v.datetime` is set, VERSION-only otherwise. /// DATE/TIME are zero-padded fixed-width YYYYMMDD/HHMMSS (L2310-2312 /// specifies positional *forms*, which requires leading zeros). pub fn writeClientVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 { if (v.datetime) |dt| { std.debug.assert(dt.date < 100000000 and dt.time < 1000000); const needed = std.fmt.count("pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time }); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time }); } const needed = std.fmt.count("pragma client-version {d}", .{v.version}); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma client-version {d}", .{v.version}); } /// §10.1 r11 L2319-2323 — write `pragma server-version VERSION DATE /// TIME`. Always the full form (the server never emits it without /// DATE/TIME, r12); same zero-padded fixed-width form as /// `writeClientVersion`. pub fn writeServerVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(v.datetime != null); const dt = v.datetime.?; std.debug.assert(dt.date < 100000000 and dt.time < 1000000); const needed = std.fmt.count("pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time }); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time }); } /// §10.2 r15 L2333 — `pragma uv-hash HASH`; HASH is `items[2]`. pub fn parseUvHash(tokens: Tokens) ?[]const u8 { return if (tokens.len >= 3) tokens.items[2] else null; } /// §8.4 — a request class deferred on clone's first round: the client /// suppresses login, config exchange, and uv-hash while `op == .clone` /// and `round == 0`. One definition shared by every deferred pragma /// (§10.2 r16, §10.3 r22) rather than each re-deriving the predicate. /// §8.3 L1974-1975 gates the same round on the login side: round 0 knows /// no project code yet, so both body-login and cookie-login transport /// belong under this one predicate too. `loginTransport` now consumes it /// for login gating alongside `clientMaySendUvHash` for uv-hash gating — /// one predicate, both deferred surfaces. pub fn sendableAfterCloneRound0(op: Operation, round: u64) bool { return !(op == .clone and round == 0); } /// §10.2 r16 L2335-2337, §9.1.1 L2058-2059 — uv-hash goes out at most /// once per session and never on clone round 0. pub fn clientMaySendUvHash(hash_sent: bool, op: Operation, round: u64) bool { return !hash_sent and sendableAfterCloneRound0(op, round); } /// §10.2 r17/r18 L2339-2343 — the server's per-request unversioned-file /// response. pub const UvResponse = enum { none, push_ok, pull_only }; /// §10.2 r17/r18 L2339-2343, §9.1.1 L2077-2081 — matching catalog hash /// or missing read permission yields no response; otherwise the server /// grants push or pull-only depending on upload permission. pub fn serverUvResponse(match: bool, has_read: bool, may_upload: bool) UvResponse { const result: UvResponse = blk: { if (match) break :blk .none; if (!has_read) break :blk .none; break :blk if (may_upload) .push_ok else .pull_only; }; std.debug.assert(result != .push_ok or (!match and has_read and may_upload)); std.debug.assert(result != .pull_only or (!match and has_read and !may_upload)); std.debug.assert(result != .none or (match or !has_read)); return result; } /// §9.1.1 L2088-2091, §10.2 L2359-2361 — the client's per-session /// unversioned-upload state as a single enum, not two independent /// bools: the r19 pull-only latch is one field and the contradictory /// "push-ok AND pull-only" state is unrepresentable. pub const UvUpload = enum { undetermined, push_ok, pull_only }; /// §3.1 L374-399 — mirrors the "catalog sent" flag and the collapsed /// upload latch [rw-1] as the client's per-session unversioned state. pub const UvSession = struct { hash_sent: bool = false, upload: UvUpload = .undetermined, }; /// §9.1.1 L2088-2089 — pragma uv-push-ok sets upload-enabled, but MUST /// NOT override an existing pull-only latch (r19, §10.2 L2359-2361): /// once pull-only, a later push-ok is a no-op. pub fn clientOnUvPushOk(s: *UvSession) void { const old = s.upload; if (s.upload != .pull_only) s.upload = .push_ok; std.debug.assert(if (old == .pull_only) s.upload == .pull_only else s.upload == .push_ok); } /// §9.1.1 L2089-2091, §10.2 r19 L2359-2361 — pragma uv-pull-only /// latches the session unconditionally; it always wins. pub fn clientOnUvPullOnly(s: *UvSession) void { s.upload = .pull_only; std.debug.assert(s.upload == .pull_only); } // §3.5 — Request and Response Pragmas (pointer clause into §10; establishes // only that pragmas dispatch in round order and the unknown-name/unknown- // operator split, both realized by the existing §5/§10 primitives). // --------------------------------------------------------------------------- // Section 10.3 — Configuration Exchange // --------------------------------------------------------------------------- /// §5.2 — tokenization splits a card on Fossil-whitespace runs, so an /// unquoted wire arg (group name, pragma arg) that embeds one would /// silently split into two tokens on the wire; shared by every writer /// below that formats a raw token straight onto the wire. pub fn containsFossilWhitespace(s: []const u8) bool { for (s) |b| { if (isFossilWhitespace(b)) return true; } return false; } /// §10.3 L2381-2383 — the scalar-settings marker: a fixed GROUPNAME /// that carries every scalar repository setting regardless of which /// group the client actually requested (r24, r27). pub const config_scalar_marker: []const u8 = "/config"; /// §10.3 L2381-2404 — a config card's payload is either the fixed /// scalar marker or a table GROUPNAME (r24 vs. r28). pub const ConfigKind = enum { scalar, table }; /// §10.3 r24/r28 — classify a GROUPNAME by exact match against the /// scalar marker; anything else is a table-type group name. pub fn configKind(groupname: []const u8) ConfigKind { return if (std.mem.eql(u8, groupname, config_scalar_marker)) .scalar else .table; } /// §10.3 r20 L2369-2370 — the reqconfig NAME MUST-begin-with-/ gate. pub fn configNameHasSlashPrefix(name: []const u8) bool { return name.len >= 1 and name[0] == '/'; } /// §10.3 r20 — write `reqconfig NAME`. pub fn writeReqconfig(name: []const u8, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(configNameHasSlashPrefix(name)); std.debug.assert(!containsFossilWhitespace(name)); const needed = std.fmt.count("reqconfig {s}", .{name}); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "reqconfig {s}", .{name}); } /// §10.3 r20/r21 — the requested group name off a dispatched reqconfig /// card; null when the card carries no NAME token. pub fn parseReqconfigName(tokens: Tokens) ?[]const u8 { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .reqconfig); return if (tokens.len >= 2) tokens.items[1] else null; } /// §10.3 r21 L2370-2372 — the server's reqconfig disposition: `.ignore` /// emits no error card at all, unlike every fatal disposition in §5. pub const ReqconfigDisposition = enum { reply, ignore }; /// §10.3 r21 — Read permission is the sole gate; unauthorized is a /// silent no-op, not a rejection card. pub fn serverReqconfigDisposition(has_read: bool) ReqconfigDisposition { return if (has_read) .reply else .ignore; } /// §10.3 r23 L2377-2378 — a parsed `config GROUPNAME SIZE` card header. pub const ConfigCard = struct { groupname: []const u8, size: u32 }; /// §10.3 r32 L2430-2431 — a negative SIZE is distinguished from an /// ordinary parse failure because it names a specific fatal action /// (abort the transaction) rather than an unspecified malformed card. pub const ConfigError = error{ Malformed, NegativeSize }; /// §10.3 r23/r32 — parse `config GROUPNAME SIZE`. SIZE is read as a /// signed field so a negative literal is representable and reported as /// `NegativeSize` rather than folded into `Malformed`; a positive value /// exceeding `u32` is rejected outright, never silently truncated. pub fn parseConfigCard(tokens: Tokens) ConfigError!ConfigCard { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .config); if (tokens.len < 3) return ConfigError.Malformed; const raw = std.fmt.parseInt(i64, tokens.items[2], 10) catch return ConfigError.Malformed; if (raw < 0) return ConfigError.NegativeSize; if (raw > std.math.maxInt(u32)) return ConfigError.Malformed; const size: u32 = @intCast(raw); return .{ .groupname = tokens.items[1], .size = size }; } /// §10.3 r23 — write `config GROUPNAME SIZE`. pub fn writeConfigCard(groupname: []const u8, size: u32, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(!containsFossilWhitespace(groupname)); const needed = std.fmt.count("config {s} {d}", .{ groupname, size }); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "config {s} {d}", .{ groupname, size }); } /// §10.3 r23/r29/r33 — the SIZE content bytes plus the offset just past /// the mandatory trailing newline. pub const ConfigPayload = struct { bytes: []const u8, next: usize }; /// §10.3 r29 L2419-2421 — the two ways a config payload frame fails: /// the declared span (or its trailing newline byte) runs past the /// buffer, or the byte immediately after SIZE bytes is not the LF r29 /// mandates. pub const ConfigFrameError = error{ Truncated, MissingNewline }; /// §10.3 r23/r29/r33 — slice out the SIZE content bytes starting at /// `start` and consume the single mandatory trailing newline. /// Overflow-safe: `start + size + 1` is computed with checked addition /// so an adversarial SIZE cannot wrap `usize` into passing the bound /// check. pub fn consumeConfigPayload(body: []const u8, start: usize, size: u32) ConfigFrameError!ConfigPayload { std.debug.assert(start <= body.len); const end = std.math.add(usize, start, size) catch return ConfigFrameError.Truncated; const next = std.math.add(usize, end, 1) catch return ConfigFrameError.Truncated; if (next > body.len) return ConfigFrameError.Truncated; if (body[end] != '\n') return ConfigFrameError.MissingNewline; const result: ConfigPayload = .{ .bytes = body[start..end], .next = next }; std.debug.assert(result.next == start + @as(usize, size) + 1); std.debug.assert(body[start + @as(usize, size)] == '\n'); return result; } /// §10.3 r26 L2391-2393 — wrap `raw` in Fossil quote encoding: enclosing /// single quotes, every interior single quote doubled (SQL-style /// string-literal escaping). pub fn fossilQuote(raw: []const u8, out: []u8) []u8 { std.debug.assert(out.len >= 2 + 2 * raw.len); var i: usize = 0; out[i] = '\''; i += 1; for (raw) |b| { out[i] = b; i += 1; if (b == '\'') { out[i] = '\''; i += 1; } } out[i] = '\''; i += 1; const result = out[0..i]; std.debug.assert(result.len >= 2 and result[0] == '\'' and result[result.len - 1] == '\''); std.debug.assert(result.len <= 2 + 2 * raw.len); return result; } /// §10.3 r26 — a quoted token missing its enclosing quotes, or with an /// interior single quote that is not doubled, is malformed. pub const QuoteError = error{Malformed}; /// §10.3 r26 L2393-2395 — undo `fossilQuote`: strip the enclosing /// quotes and undouble every interior `''` back to `'`. A single pass /// bounded by `quoted.len`. pub fn fossilUnquote(quoted: []const u8, out: []u8) QuoteError![]const u8 { if (quoted.len < 2 or quoted[0] != '\'' or quoted[quoted.len - 1] != '\'') return QuoteError.Malformed; std.debug.assert(out.len >= quoted.len - 2); const inner_end = quoted.len - 1; var i: usize = 1; var j: usize = 0; while (i < inner_end) { if (quoted[i] == '\'') { if (i + 1 < inner_end and quoted[i + 1] == '\'') { out[j] = '\''; j += 1; i += 2; } else { return QuoteError.Malformed; } } else { out[j] = quoted[i]; j += 1; i += 1; } } const result = out[0..j]; std.debug.assert(result.len <= quoted.len - 2); return result; } /// §10.3 r25 L2386-2390 — the scalar payload's two quoted fields, still /// in their wire-quoted form (unquoting is the caller's explicit /// `fossilUnquote` step, so this parse stays allocation-free). pub const ScalarConfig = struct { mtime: u64, quoted_name: []const u8, quoted_value: []const u8 }; /// §10.3 r25/r26 — scan one Fossil-quoted token starting at `payload[start]` /// (which must be `'`); returns the index just past its closing quote, /// or null if the payload ends before the token closes. Bounded by /// `payload.len`. fn scanQuoted(payload: []const u8, start: usize) ?usize { std.debug.assert(start < payload.len); std.debug.assert(payload[start] == '\''); var i: usize = start + 1; while (i < payload.len) { if (payload[i] == '\'') { if (i + 1 < payload.len and payload[i + 1] == '\'') { i += 2; continue; } return i + 1; } i += 1; } return null; } /// §10.3 r25 L2386-2390 — parse `MTIME SP QUOTED-NAME SP value SP /// QUOTED-VALUE`: the literal token `value` appears verbatim between /// the two quoted fields. A single left-to-right pass bounded by /// `payload.len`; any structural miss is `Malformed`. pub fn parseScalarConfig(payload: []const u8) error{Malformed}!ScalarConfig { const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed; const mtime = std.fmt.parseInt(u64, payload[0..sp1], 10) catch return error.Malformed; var i = sp1 + 1; if (i >= payload.len or payload[i] != '\'') return error.Malformed; const name_end = scanQuoted(payload, i) orelse return error.Malformed; const quoted_name = payload[i..name_end]; i = name_end; if (i >= payload.len or payload[i] != ' ') return error.Malformed; i += 1; const literal = "value"; if (i + literal.len > payload.len or !std.mem.eql(u8, payload[i .. i + literal.len], literal)) return error.Malformed; i += literal.len; if (i >= payload.len or payload[i] != ' ') return error.Malformed; i += 1; if (i >= payload.len or payload[i] != '\'') return error.Malformed; const value_end = scanQuoted(payload, i) orelse return error.Malformed; if (value_end != payload.len) return error.Malformed; const result: ScalarConfig = .{ .mtime = mtime, .quoted_name = quoted_name, .quoted_value = payload[i..value_end] }; std.debug.assert(result.quoted_name.len >= 2 and result.quoted_name[0] == '\'' and result.quoted_name[result.quoted_name.len - 1] == '\''); std.debug.assert(result.quoted_value.len >= 2 and result.quoted_value[0] == '\'' and result.quoted_value[result.quoted_value.len - 1] == '\''); return result; } /// §10.3 r25/r26 — assemble `MTIME SP 'NAME' SP value SP 'VALUE'`, /// quoting the raw name/value with `fossilQuote`. pub fn writeScalarConfig(mtime: u64, raw_name: []const u8, raw_value: []const u8, out: []u8) std.fmt.BufPrintError![]u8 { const mtime_len = std.fmt.count("{d}", .{mtime}); const name_bound = 2 + 2 * raw_name.len; const value_bound = 2 + 2 * raw_value.len; const literal = " value "; const needed = mtime_len + 1 + name_bound + literal.len + value_bound; std.debug.assert(out.len >= needed); var i: usize = 0; const mtime_str = try std.fmt.bufPrint(out[i..], "{d}", .{mtime}); i += mtime_str.len; out[i] = ' '; i += 1; const name_q = fossilQuote(raw_name, out[i .. i + name_bound]); i += name_q.len; @memcpy(out[i .. i + literal.len], literal); i += literal.len; const value_q = fossilQuote(raw_value, out[i .. i + value_bound]); i += value_q.len; const result = out[0..i]; std.debug.assert(result.len <= needed); return result; } /// §10.3 r31 L2427-2429 — the exact error-card text for an /// unauthorized pushed config card. pub const config_push_unauthorized_text: []const u8 = "not authorized to push configuration"; /// §10.3 r31/r33 — the server's disposition of a pushed config card. pub const ConfigPushDecision = enum { apply, reject }; /// §10.3 r31 — Admin capability is the sole gate for a pushed config /// card; without it the card is rejected and not applied. pub fn serverConfigPushDecision(has_admin: bool) ConfigPushDecision { return if (has_admin) .apply else .reject; } // --------------------------------------------------------------------------- // Section 10.4 — Commit-Lock Pragmas // --------------------------------------------------------------------------- /// §10.4 L2444-2445 — `pragma ci-lock HASH CLIENT-ID` arguments. pub const CiLock = struct { hash: []const u8, client_id: []const u8 }; /// §10.4 — HASH/CLIENT-ID off a dispatched `pragma ci-lock` card. pub fn parseCiLock(tokens: Tokens) ?CiLock { std.debug.assert(tokens.len >= 2); std.debug.assert(parseOperator(tokens.items[0]) == .pragma); std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock"); if (tokens.len < 4) return null; return .{ .hash = tokens.items[2], .client_id = tokens.items[3] }; } /// §10.4 — write `pragma ci-lock HASH CLIENT-ID`. pub fn writeCiLock(hash: []const u8, client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(hash.len > 0 and !containsFossilWhitespace(hash)); std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id)); const needed = std.fmt.count("pragma ci-lock {s} {s}", .{ hash, client_id }); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma ci-lock {s} {s}", .{ hash, client_id }); } /// §10.4 L2446-2449 — the server's disposition of a `ci-lock` request: /// granted when unheld or already held by the same requester, failed /// when held by a different client. pub const CiLockResult = enum { granted, failed }; /// §10.4 r36 — `holder` is the current lock holder's login, if any. pub fn serverCiLockResult(holder: ?[]const u8, requester: []const u8) CiLockResult { const result: CiLockResult = blk: { const h = holder orelse break :blk .granted; break :blk if (std.mem.eql(u8, h, requester)) .granted else .failed; }; std.debug.assert(result != .failed or (holder != null and !std.mem.eql(u8, holder.?, requester))); return result; } /// §10.4 L2531 — `pragma ci-lock-fail LOGIN MTIME` arguments. pub const CiLockFail = struct { login: []const u8, mtime: u64 }; /// §10.4 L2538-2556 — LOGIN/MTIME off a dispatched `pragma ci-lock-fail` /// card; null on a missing or non-decimal MTIME. A parsed fault does /// not by itself abort the round (L2538-2542: the client records the /// fault and continues processing the remainder of the round); it only /// gates a later commit against the locked leaf, per /// `commitMayProceedAfterCiLockFail`. A negative or non-integer MTIME /// already fails to parse here and is null (L2542-2547: a receiver MAY /// treat that as fatal; a non-negative MTIME MUST NOT be a wire error). pub fn parseCiLockFail(tokens: Tokens) ?CiLockFail { std.debug.assert(tokens.len >= 2); std.debug.assert(parseOperator(tokens.items[0]) == .pragma); std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock-fail"); if (tokens.len < 4) return null; const mtime = std.fmt.parseInt(u64, tokens.items[3], 10) catch return null; return .{ .login = tokens.items[2], .mtime = mtime }; } /// §10.4 L2531 — write `pragma ci-lock-fail LOGIN MTIME`. pub fn writeCiLockFail(login: []const u8, mtime: u64, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(login.len > 0 and !containsFossilWhitespace(login)); const needed = std.fmt.count("pragma ci-lock-fail {s} {d}", .{ login, mtime }); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma ci-lock-fail {s} {d}", .{ login, mtime }); } /// §10.4 L2548-2556 — with a recorded ci-lock fault, a commit against /// the same check-in MUST NOT complete if it would fork the locked /// leaf, unless the user requested an override that removes the /// fork-avoidance guard. pub fn commitMayProceedAfterCiLockFail( fault_recorded: bool, would_create_fork: bool, override_requested: bool, ) bool { const result = !(fault_recorded and would_create_fork and !override_requested); std.debug.assert(fault_recorded or result); // §10.4 L2548: no recorded fault -> commit proceeds std.debug.assert(!override_requested or result); // §10.4 L2551-2553: an override always permits return result; } /// §10.4 L2561 — CLIENT-ID off a dispatched `pragma ci-unlock` card. pub fn parseCiUnlock(tokens: Tokens) ?[]const u8 { std.debug.assert(tokens.len >= 2); std.debug.assert(parseOperator(tokens.items[0]) == .pragma); std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-unlock"); return if (tokens.len >= 3) tokens.items[2] else null; } /// §10.4 L2561 — write `pragma ci-unlock CLIENT-ID`. pub fn writeCiUnlock(client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id)); const needed = std.fmt.count("pragma ci-unlock {s}", .{client_id}); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma ci-unlock {s}", .{client_id}); } /// §10.4 L2561-2563 — a client with a known client identifier sends /// ci-unlock in a later round (round > 0) when no lock is pending for /// the current exchange. pub fn clientSendsCiUnlock(has_client_id: bool, lock_pending: bool, round: u64) bool { return has_client_id and !lock_pending and round > 0; } // --------------------------------------------------------------------------- // Section 10.5 — Private-Content and Discovery Pragmas // --------------------------------------------------------------------------- /// ABNF App. B.7 L3566-3575 — the seven `"pragma" SP NAME LF` productions /// that carry no argument (send-private, send-catalog, req-links, /// req-clusters, avoid-delta-manifests, uv-push-ok, uv-pull-only). The /// other seven names all carry `SP fossilized` / `SP decimal` / `SP /// artifact-hash` after NAME. Derived from the ABNF, not from this /// batch's callers, so `writeFlagPragma` stays the general arg-less /// writer rather than one special-cased to §10.5. pub fn pragmaIsArgless(name: PragmaName) bool { return switch (name) { .@"send-private", .@"send-catalog", .@"req-links", .@"req-clusters", .@"avoid-delta-manifests", .@"uv-push-ok", .@"uv-pull-only", => true, .@"client-version", .@"server-version", .@"uv-hash", .@"ci-lock", .@"ci-lock-fail", .@"ci-unlock", .@"client-url", => false, }; } /// §10 ABNF — write `pragma NAME` for an arg-less pragma. `@tagName` /// reproduces the wire token exactly: `PragmaName` fields are spelled to /// match the wire names (`parsePragmaName` relies on the same identity). /// Precondition `pragmaIsArgless(name)` — a name that carries an argument /// is a caller bug, not a runtime error. pub fn writeFlagPragma(name: PragmaName, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(pragmaIsArgless(name)); const tag = @tagName(name); const needed = std.fmt.count("pragma {s}", .{tag}); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "pragma {s}", .{tag}); } /// §10.5 L2474-2475 — the exact unauthorized-send-private error text. pub const send_private_unauthorized_text = "not authorized to sync private content"; /// §10.5 L2472-2476 — the server's disposition of a `send-private` /// pragma. pub const SendPrivateDecision = enum { enable, reject }; /// §10.5 r41 — private-content transfer is enabled for the session only /// when the requesting user holds the Private (x) capability; carries no /// state of its own, so a repeated call with the same input always /// yields the same decision (r43's idempotence). pub fn serverSendPrivateDecision(has_private_x: bool) SendPrivateDecision { return if (has_private_x) .enable else .reject; } /// §7.5 L2482-2483 / §10.5 — the send-side mirror of /// `serverSendPrivateDecision`: pulling private content MUST be preceded by a /// send-private pragma, itself gated on the client holding the Private (x) /// capability, so a client emits send-private only when it wants private sync /// and holds x. The caller derives `has_private_x` from /// `CapabilitySet.contains(.private)`. Pure — no state, so idempotent. pub fn clientSendsSendPrivate(wants_private: bool, has_private_x: bool) bool { const result = wants_private and has_private_x; std.debug.assert(result == (wants_private and has_private_x)); return result; } /// §10.5 r43/r48 — the two per-pragma send schedules the RFC states in /// §10.5. Partial by design: the RFC gives a schedule for exactly three /// names (send-private, client-url, req-links); the other eleven are /// RFC-silent, so this returns `null` for them rather than fabricating a /// total table the spec never states. pub const PragmaSchedule = enum { once_pre_loop, every_private_round }; /// §10.5 r43 L2477-2479 (send-private repeats every private-sync round) /// vs. r48 L2488-2490 (client-url/req-links go once, before the round /// loop). pub fn pragmaSchedule(name: PragmaName) ?PragmaSchedule { return switch (name) { .@"send-private" => .every_private_round, .@"client-url", .@"req-links" => .once_pre_loop, else => null, }; } /// §10.5 r47, ABNF `client-url-pragma` L3570 — write `pragma client-url /// <fossilized(url)>` in place: the fixed prefix goes straight into /// `out`, then `artifact.encodeEscapes` fills the remainder — one /// buffer, no scratch. Precondition `out.len >= prefix.len + /// 2*url.len` (the encoder's own worst case), so unlike the sibling /// `write*` helpers this never touches `std.fmt.bufPrint` and cannot /// fail — no error union to force a `try` on an unreachable path. pub fn writeClientUrl(url: []const u8, out: []u8) []u8 { const prefix = "pragma client-url "; std.debug.assert(out.len >= prefix.len + 2 * url.len); @memcpy(out[0..prefix.len], prefix); const encoded = artifact.encodeEscapes(url, out[prefix.len..]); return out[0 .. prefix.len + encoded.len]; } /// §10.5 r47 — read the URL back off a dispatched `pragma client-url` /// card: `items[2]` decoded. Null on a short card (negative space); /// never indexes past `tokens.len`. pub fn parseClientUrl(tokens: Tokens, out: []u8) ?[]const u8 { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .pragma); if (tokens.len < 3) return null; std.debug.assert(parsePragmaName(tokens.items[1]) == .@"client-url"); return artifact.decodeEscapes(tokens.items[2], out); } // --------------------------------------------------------------------------- // Section 11 — Error and Message Cards // --------------------------------------------------------------------------- /// §11.1 r11 L2648-2650, App. A.2 decoded text L3211 — the exact decoded text /// emitted when the SHA3-256 peer-version gate (§7.6) fires. pub const peer_version_error_text = "Fossil version 2.0 or later required."; /// §11.1/§11.2 — an error card is a fatal protocol signal (outside clone /// round 0); a message card is a non-fatal advisory. TEXT is encoded /// identically on both (§11.1 r2, §11.2 r2), so one writer/parser pair serves /// both — only the leading operator word differs. pub const SignalKind = enum { err, message }; /// §5.2 L1263-1272 (usage sites: §11.1 r1/r2, §11.2 r1/r2, §3.6 L703-707) /// — write `OP TEXT`, TEXT fossilize-encoded in place. Shared body for /// every single-text-argument card: `op` carries its own trailing SP, then /// `artifact.encodeEscapes` fills the remainder — one buffer, no scratch. /// Like `writeClientUrl`, this never touches `std.fmt.bufPrint` and cannot /// fail, so it returns `[]u8` directly rather than an error union nothing /// ever populates. fn writeTextArgCard(op: []const u8, text: []const u8, out: []u8) []u8 { std.debug.assert(out.len >= op.len + 2 * text.len); @memcpy(out[0..op.len], op); const encoded = artifact.encodeEscapes(text, out[op.len..]); return out[0 .. op.len + encoded.len]; } /// §11.1 r1/r2 L2604-2610, §11.2 r1/r2 L2654-2658 — write `error TEXT` /// or `message TEXT`; the operator word is the only thing that varies. pub fn writeSignalCard(kind: SignalKind, text: []const u8, out: []u8) []u8 { const op: []const u8 = switch (kind) { .err => "error ", .message => "message ", }; return writeTextArgCard(op, text, out); } /// §11.1 r2, §11.2 r2 — read TEXT back off a dispatched `error`/ /// `message` card: `items[1]` decoded. Null on a card with no TEXT /// token (negative space); never indexes past `tokens.len`. pub fn parseSignalText(tokens: Tokens, out: []u8) ?[]const u8 { std.debug.assert(tokens.len >= 1); if (tokens.len < 2) return null; return artifact.decodeEscapes(tokens.items[1], out); } /// §11.1 r4/r10 — the client's disposition of a received `error` card. pub const ClientErrorAction = enum { discard, terminate }; /// §11.1 r4 L2614-2616 (outside clone round 0: terminate) / r10 L2642-2644 /// (first round of a clone: silently discard) / r9 L2639-2640 (no HTTP- /// status input in scope — the signal lives wholly in the message body). /// Exhaustive over `ClientState`: only `clone_round0` discards. pub fn clientOnErrorCard(state: ClientState) ClientErrorAction { return switch (state) { .clone_round0 => .discard, .init, .exchange, .terminated, .aborted => .terminate, }; } /// §11.1 L2619-2629 — the fatal-error sites the RFC distinguishes by /// whether the server resets prior partial output before emitting the /// error card. pub const ErrorSite = enum { wrong_project, auth_failure, duplicate_login, unknown_operator, peer_version_gate, unauthorized_send_private, private_card, }; /// §11.1 L2619-2629 — reset (discard prior partial output) vs. append /// (extend the already-built response) before emitting the error card. pub const ErrorReset = enum { reset, append }; /// §11.1 r6 L2619-2623 (wrong project, auth failure, duplicate login, /// unknown operator -> reset) vs. r8 L2626-2629 (peer-version gate, /// unauthorized send-private, private card -> append). pub fn serverErrorReset(site: ErrorSite) ErrorReset { return switch (site) { .wrong_project, .auth_failure, .duplicate_login, .unknown_operator => .reset, .peer_version_gate, .unauthorized_send_private, .private_card => .append, }; } /// §11.2 — the client's disposition of a received `message` card. pub const MessageAction = enum { pull_only_silent, display_optional }; /// §11.2 r4 L2662-2665, ambiguities.md §11.2 Reading A (settled) — the /// glob `pull only *` carries its separating space; the trigger is the /// literal 10-byte prefix `"pull only "`, so a bare `"pull only"` (no /// trailing content) does not match. pub fn matchesPullOnly(decoded: []const u8) bool { return std.mem.startsWith(u8, decoded, "pull only "); } /// §11.2 item 1/2 — the client's disposition of a received `message` card. /// The pull-only suppression path (r4) is entered only when push intent is /// *live* (`push_active`) AND the decoded payload matches `pull only *`; a /// match while push is already clear (r14) or any non-matching payload (r9) /// falls through to the advisory display path. pub fn clientOnMessageCard(push_active: bool, decoded: []const u8) MessageAction { return if (push_active and matchesPullOnly(decoded)) .pull_only_silent else .display_optional; } // --------------------------------------------------------------------------- // Section 3.6 — Cookie Card // --------------------------------------------------------------------------- /// §3.6 L703, grammar `cookie-card = "cookie" SP fossilized LF` (App. B.4) /// — the opaque TEXT is fossilize-encoded exactly like error/message TEXT. /// One writer serves both the server's emit (L703) and the client's return /// (L706-707): the card shape is identical either direction. pub fn writeCookieCard(text: []const u8, out: []u8) []u8 { return writeTextArgCard("cookie ", text, out); } /// §3.6 L704-705 — the client's single held sync cookie: storing a new /// value replaces any previously held one ("replacing any previously held /// cookie"). Client-only: the server keeps no cookie state of its own /// (L709), so this type is never instantiated server-side. `buf` is bounded /// by the §5.2 card-line ceiling (`max_line_len`) — a longer TEXT could /// never have arrived as a card line in the first place. pub const CookieStore = struct { buf: [max_line_len]u8 = undefined, len: usize = 0, held: bool = false, /// §3.6 L704-705 — overwrite the decoded opaque value, discarding /// whatever was held before. pub fn store(self: *CookieStore, text: []const u8) void { std.debug.assert(text.len <= self.buf.len); @memcpy(self.buf[0..text.len], text); self.len = text.len; self.held = true; std.debug.assert(self.len == text.len and self.held); } /// §3.6 L704-705, §4.3 — store a wire cookie token: defossilize `encoded` /// (a reply-body slice, disjoint from `buf`) straight into `buf`, then set /// the length and held flag. Allocation-free: the decoded form never grows /// past the encoded token (§4.3), and the token can be at most one card /// line (`max_line_len`), so `buf` always fits it — the cookie OOM path is /// defined out of existence rather than allocating a transient scratch. pub fn storeFossilized(self: *CookieStore, encoded: []const u8) void { std.debug.assert(encoded.len <= self.buf.len); const decoded = artifact.decodeEscapes(encoded, &self.buf); self.len = decoded.len; self.held = true; std.debug.assert(self.len <= encoded.len and self.held); } /// §3.6 L706-707/L712-713 — null when nothing has ever been stored, so /// a client that never returns a cookie (L713) has nothing to send. pub fn value(self: *const CookieStore) ?[]const u8 { return if (self.held) self.buf[0..self.len] else null; } }; // --------------------------------------------------------------------------- // Section 7.6 / 12.4 — SHA3-256 Peer-Version Gate // --------------------------------------------------------------------------- /// §7.6 L1784, §10.1 L2308-2309 — "The value 20000 corresponds to Fossil /// release 2.0." pub const sha3_min_peer_version: u32 = 20000; /// §7.6 L1780-1800, §12.4 L2673-2678 — exactly two outcomes. No third /// "downgrade" outcome exists: §12.4 L2676-2678 forbids any SHA1 /// substitution or re-identification, so the gate can never rewrite a /// name — the two-variant enum forbids that structurally. pub const GateDecision = enum { emit, refuse }; /// §7.6 L1780-1795 — refuse iff `hash` names a SHA3-256 artifact (64 /// lowercase hex, [FossilArtifact] §3.2) AND `peer_version` is less than /// `sha3_min_peer_version`. Site-agnostic: the file/cfile/uvfile send /// sites (§7.6) all share this one decision. /// /// `peer_version` is the caller's already-recorded version-pragma integer /// for the peer — this function is a PURE function of that integer; it /// does not choose one for an unrecorded peer. Per §10.1 L2316-2327 the /// unrecorded-peer default is SIDE-DEPENDENT (server: before any /// client-version pragma, treats VERSION as 0 -> refuse; client: before /// any server-version pragma, treats VERSION as its own release integer /// -> emit for a SHA3-capable client), so selecting that default is the /// caller's responsibility, deferred to the round-loop/content-transfer /// work that owns the recorded-peer-version state. On `.refuse` the /// caller emits `writeSignalCard(.err, peer_version_error_text)` at /// `ErrorSite.peer_version_gate`. pub fn sha3PeerVersionGate(peer_version: u32, hash: []const u8) GateDecision { const is_sha3 = artifact.validateName(hash) and hash.len == 64; const d: GateDecision = if (is_sha3 and peer_version < sha3_min_peer_version) .refuse else .emit; // Downgrade-safety invariant (§12.4 L2676-2678): a non-64-char name — // every SHA1 name — is never refused, so the gate has no path that // could truncate or re-identify. Derived from hash.len, not a // restatement of the branch above. std.debug.assert(d == .emit or hash.len == 64); return d; } // §7.6 r7: the 2-field GateDecision enum is a precondition value, not a // card variant — the gate defines no new card syntax, only a yes/no on // whether the caller may write the card it already knows how to write. // §7.6 r6: the gate applies at three independent send sites (file, cfile, // uvfile) and a sender MUST apply it at each site independently. The gate // itself is site-agnostic by design (L1782 folds the uvfile "64-character // lowercase hexadecimal content hash" into the same HASH-shape test as the // file/cfile name), so at the decision layer "independent per site" reduces // to: the same pure function fires identically for three distinct 64-hex // values, standing in for the file-name, cfile-name, and uvfile-hash forms. // The three real call sites land with the future file/cfile/uvfile send // batches (§7.1/§7.2/§7.3); this pins the decision those sites will share. // App. A.2 L3102-3114: the hand-transcribed 4-line gate-fire response body // (Content-Length: 191), cited not machine-generated. zit owns no // clone_seqno/# timestamp writer or multi-card response assembler (out of // §7.6 scope), so this only pins the fixture's byte length and that the one // card §7.6 owns -- the error card -- is byte-identical to the body's first // line. Decoded-text/encoding equality is not re-asserted here (that's §11 // r8); this test is about the body shape, not the text content. // --------------------------------------------------------------------------- // Section 3.7 — Round Continuation // --------------------------------------------------------------------------- /// §3.7 — carried post-round observations (one struct, not loose args), so /// `continueExchange` stays a pure total function of "what happened last /// round." pub const RoundObservation = struct { operation: Operation, // (a) r2 — non-clone versioned progress into a non-empty phantom table. versioned_content_delivered: bool = false, phantom_created: bool = false, phantom_table_nonempty: bool = false, // (b) r3 — the per-round file-send count is greater than zero: at least // one full-content card was appended to the send buffer while processing // this reply (§3.7(b) L939-945). Per-round, not the persistent buffer. content_queued: bool = false, // (c) r4 — private-content discovery round pending. private_discovery_pending: bool = false, // (d) r5/r6 — unversioned send/gimme cycle. uv_send_table_nonempty: bool = false, uv_upload_enabled: bool = false, uvgimme_queued: bool = false, uvfile_received: bool = false, session_round: u64 = 0, // round count AFTER incrementing for the completed round // (e) r7 — modern-clone continuation. first_clone_round: bool = false, artifact_received_this_round: bool = false, clone_seqno_next: i64 = 0, // received NEXT; negatives are the fatal path's job (§8.2) artifact_count_grew: bool = false, // (f) r8 — pending configuration exchange. config_exchange_pending: bool = false, // §3.6/§3.9 — a cookie card was accepted and stored this round. A pure // observation: the cookie is "an optimisation only" (§3.6 L922), so this // feeds no §3.7 continuation condition. cookie_stored: bool = false, // §7.5 L2359-2360 — a content card bound a pending private marker this round // and was stored. Pure observation; feeds no §3.7 continuation condition. private_content_delivered: bool = false, // §7.5 L2383-2385 — a private artifact was discarded for lack of the grant. // Pure observation; it sets no versioned/phantom flag, so a discard never // feeds a §3.7 continuation condition (this is why deferred DST invariant // (b) — a discard does not stall §3.7 termination — holds by construction). private_content_discarded: bool = false, // §7.5 L2478-2480 — a private igot marked an already-held phantom private. phantom_marked_private: bool = false, // §7.5 L2480-2481 — a private igot recorded that the peer holds HASH. private_igot_peer_has: bool = false, }; /// §3.7 r2, L735-737 — condition (a): versioned progress into a non-empty /// phantom table, gated to non-clone operations (clone continuation is (e)). fn continuesVersionedProgress(obs: RoundObservation) bool { if (obs.operation == .clone) return false; const progressed = obs.versioned_content_delivered or obs.phantom_created; return progressed and obs.phantom_table_nonempty; } /// §3.7 r5/r6, L751-763 — condition (d): the unversioned send/gimme cycle. /// (d)(1) the send table is non-empty and upload is enabled; (d)(2) a /// uvgimme was queued this round, and either a uvfile came back or the /// session hasn't yet exhausted its three-round grace window. fn continuesUnversionedCycle(obs: RoundObservation) bool { const d1 = obs.uv_send_table_nonempty and obs.uv_upload_enabled; const d2 = obs.uvgimme_queued and (obs.uvfile_received or obs.session_round < 3); return d1 or d2; } /// §3.7 r7, L765-769 — condition (e): modern-clone continuation. A positive /// NEXT alone does not continue the round (L1915-1924: the peer must also /// have grown the artifact count this round). fn continuesClone(obs: RoundObservation) bool { if (obs.operation != .clone) return false; if (obs.first_clone_round or obs.artifact_received_this_round) return true; return obs.clone_seqno_next > 0 and obs.artifact_count_grew; } /// §3.7 r1-r10, L717-795 — the client issues another round iff at least one /// of conditions (a)-(f) holds; unresolved phantoms alone (r9) never do. pub fn continueExchange(obs: RoundObservation) bool { std.debug.assert(obs.clone_seqno_next >= 0); std.debug.assert(!obs.first_clone_round or obs.operation == .clone); return continuesVersionedProgress(obs) or // (a) obs.content_queued or // (b) obs.private_discovery_pending or // (c) continuesUnversionedCycle(obs) or // (d) continuesClone(obs) or // (e) obs.config_exchange_pending; // (f) } // --------------------------------------------------------------------------- // Section 3.4 — The Clone Loop (pointer clause: no new bytes, no new // mechanism — clone rides the operation-agnostic round primitives) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Section 3.8 — Client Protocol State Machine // --------------------------------------------------------------------------- /// §3.8 r12/r13, L907-922 — INIT departs to CLONE_ROUND0 for a modern clone; /// push/pull/sync all send an authenticated first round straight to EXCHANGE. pub fn initialState(op: Operation) ClientState { return switch (op) { .clone => .clone_round0, .pull, .push, .sync => .exchange, }; } /// §3.8 Table 3 — the legal events driving a round0/exchange transition. pub const Event = enum { clone_reply_processed, // r14 clone_fatal, // r15 round_continues, // r16 round_terminates, // r17 fatal_error, // r18 }; /// §3.8 Table 3, r14-r18 — the client's state transition function. Illegal /// (state, event) pairs and the absorbing terminals (`terminated`, /// `aborted`) are never re-stepped: unreachable, negative space. pub fn transition(state: ClientState, event: Event) ClientState { return switch (state) { .clone_round0 => switch (event) { .clone_reply_processed => .exchange, // r14 .clone_fatal => .aborted, // r15 .round_continues, .round_terminates, .fatal_error => unreachable, }, .exchange => switch (event) { .round_continues => .exchange, // r16 .round_terminates => .terminated, // r17 .fatal_error => .aborted, // r18 .clone_reply_processed, .clone_fatal => unreachable, }, .init, .terminated, .aborted => unreachable, }; } /// §3.8 r19, L965-972 — the closed nine-cause fatal-error set, each cited to /// its governing subsection. pub const FatalCause = enum { // server-emitted error cards -- silently discarded in CLONE_ROUND0 (§11.1). error_card, // §11.1 L2604-2607 project_code_mismatch, // §9.1 -> error card, §11.1 L2619-2623 auth_failure, // §6 -> error card, §11.1 L2619-2623 peer_version_refusal, // §7.6 L1797-1803 -> error card // client-detected faults -- fatal in every state, round0 included. unknown_operator, // §3.9 L983-985 / §5 malformed_content, // §3.9 L983-985 / §7 over_length_line, // §5 L2666 redirect_limit, // §4 L1153 / Table-3 r15 clone_seqno_negative, // §8.2 L1936-1937 }; /// §11.1 L2619-2623, §7.6 L1797-1803 — the four causes above that reach the /// client only as a server-emitted `error` card, so they inherit /// `clientOnErrorCard`'s round-0 discard exception. fn deliveredByErrorCard(cause: FatalCause) bool { return switch (cause) { .error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal => true, .unknown_operator, .malformed_content, .over_length_line, .redirect_limit, .clone_seqno_negative => false, }; } /// §3.8 r19 — the nine-cause set is fatal in every state, except that the /// four error-card-delivered causes are discarded in CLONE_ROUND0, per /// `clientOnErrorCard`'s single round-0 exception (reused, not restated). pub fn causeIsFatal(state: ClientState, cause: FatalCause) bool { if (deliveredByErrorCard(cause)) return clientOnErrorCard(state) == .terminate; return true; } /// §8.2 L1936-1937 — a negative clone_seqno is an unconditional abort, in /// every state including CLONE_ROUND0 (§3.8 L805-807: the cited section /// governs where it differs from the state-machine restatement). pub fn cloneSeqnoIsFatal(next: i64) bool { return next < 0; } // --------------------------------------------------------------------------- // Section 3.9 — Robustness Posture (coverage over the settled authorities; // §3.9 is a restatement, so no new mechanism — each clause binds to the // existing classifier/state-machine that owns it). // --------------------------------------------------------------------------- // §3.9 r4 helper — a canonical card is single-space separated, has no // leading/trailing Fossil whitespace, no separator run wider than one byte, // and re-tokenizes without surplus (§3.9 L994-995). // --------------------------------------------------------------------------- // Section 3.10 — Round Message Layout (non-authoritative quick reference, // L1016-1018: §3.2 and each card's owning section govern; these tests bind // the restated rows onto the authorities, adding no §3.10 mechanism). // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Section 3.1 / 3.3 — Round Tables & Phantom Lifecycle // --------------------------------------------------------------------------- /// §3.1 L411-417, r26/r27 — the two per-round tracking tables (Table 1: peer- /// has, peer-lacks). Both are created-or-reset at round start and torn down /// at round end (r20/r21, L433-436); they own no state past that boundary. /// The string maps do not own their keys, so `reset`/`deinit` free the /// `gpa.dupe`'d hashes explicitly, mirroring `MemStore` (repostate.zig). pub const RoundTables = struct { gpa: std.mem.Allocator, peer_has: std.StringHashMapUnmanaged(void) = .empty, peer_lacks: std.StringHashMapUnmanaged(void) = .empty, pub fn init(gpa: std.mem.Allocator) RoundTables { return .{ .gpa = gpa }; } pub fn deinit(self: *RoundTables) void { freeKeys(self.gpa, &self.peer_has); self.peer_has.deinit(self.gpa); freeKeys(self.gpa, &self.peer_lacks); self.peer_lacks.deinit(self.gpa); self.* = undefined; } /// r20/r21, L433-436 — create-or-reset at a round boundary: free every /// owned key and empty both tables, keeping bucket storage (capacity is /// bounded by one round's hash count). pub fn reset(self: *RoundTables) void { freeKeys(self.gpa, &self.peer_has); self.peer_has.clearRetainingCapacity(); freeKeys(self.gpa, &self.peer_lacks); self.peer_lacks.clearRetainingCapacity(); std.debug.assert(self.peer_has.count() == 0 and self.peer_lacks.count() == 0); } /// r26, L630-633 — record a hash the peer announced or sent this round. pub fn recordPeerHas(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void { try recordInto(self.gpa, &self.peer_has, hash); std.debug.assert(self.peerHas(hash)); } /// r27, L633-637 — record a hash the peer requested with gimme this round. pub fn recordPeerLacks(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void { try recordInto(self.gpa, &self.peer_lacks, hash); std.debug.assert(self.peerLacks(hash)); } pub fn peerHas(self: *const RoundTables, hash: []const u8) bool { return self.peer_has.contains(hash); } pub fn peerLacks(self: *const RoundTables, hash: []const u8) bool { return self.peer_lacks.contains(hash); } fn freeKeys(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void)) void { var it = map.keyIterator(); while (it.next()) |key| gpa.free(key.*); } /// Reserves capacity on `map` itself before dupe-ing `hash`, so the /// trailing insert is `putAssumeCapacity` (infallible) and an OOM can /// only happen before any owned memory exists — no partial state, no /// errdefer needed. A repeat record of an already-held key returns /// before the dupe, so it never leaks a duplicate key. fn recordInto(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void), hash: []const u8) error{OutOfMemory}!void { std.debug.assert(hash.len == 40 or hash.len == 64); if (map.contains(hash)) return; try map.ensureUnusedCapacity(gpa, 1); const owned = try gpa.dupe(u8, hash); map.putAssumeCapacity(owned, {}); } }; /// §3.3 r24(1), L621-625 — whether `side` can obtain content in operation /// `op`. Sync is bidirectional, so it appears on both arms. pub fn igotCanObtain(side: Side, op: Operation) bool { return switch (side) { .client => switch (op) { .pull, .clone, .sync => true, .push => false, }, .server => switch (op) { .push, .sync => true, .pull, .clone => false, }, }; } /// §3.3 L849-853, §3.2 L763 — whether the client may upload content in /// response to a peer gimme: only when the operation direction permits /// sending — push, and the push half of sync. pull and clone are /// receive-only. (The truth table coincides with the server-obtains set of /// `igotCanObtain`, but this names the distinct send-direction decision.) pub fn clientSendsContent(op: Operation) bool { return op == .push or op == .sync; } pub const IgotDisposition = enum { create_phantom, ignore }; /// §3.3 r24(1), L621-625 — igot creates a phantom iff the receiver does not /// already hold the artifact and is in an operation state that can obtain it. pub fn igotDisposition(side: Side, op: Operation, already_holds: bool) IgotDisposition { const result: IgotDisposition = if (!already_holds and igotCanObtain(side, op)) .create_phantom else .ignore; std.debug.assert(result != .create_phantom or (!already_holds and igotCanObtain(side, op))); return result; } /// §3.3 (a)/(b)/(c), L644-652 — the freshly-per-round gimme eligibility /// filter inputs. pub const Eligibility = struct { peer_lacks: bool, shunned: bool, private: bool, private_sync_enabled: bool, }; /// §3.3, L644-652 — order (a) peer-lacks, (b) shunned, (c) private-without- /// sync-enabled; a phantom is gimme-eligible only if none reject it. pub fn gimmeEligible(e: Eligibility) bool { if (e.peer_lacks) return false; if (e.shunned) return false; if (e.private and !e.private_sync_enabled) return false; const result = true; std.debug.assert(!e.peer_lacks and !e.shunned); return result; } pub const GimmeResponse = enum { send, silent_skip }; /// §3.3, L654-662 — inbound-gimme disposition. Caller records peer-lacks /// (r27) before calling. `.send` only when held, direction permits sending, /// and policy permits disclosure; every other case is a silent skip — no /// content, no error card, no deferred send (r34/r35 and the policy-denied /// case fold to the same byte-neutral outcome since none emits a card). pub fn gimmeResponse(holds: bool, direction_permits: bool, policy_permits: bool) GimmeResponse { const result: GimmeResponse = if (holds and direction_permits and policy_permits) .send else .silent_skip; return result; } /// §5.1 — append one card line into a growable body: `slice` carries the /// card tokens without a terminating LF (as every card writer returns), and /// this frames it with the single LF §5.1 requires between cards. fn appendCard(gpa: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), slice: []const u8) error{OutOfMemory}!void { try buf.appendSlice(gpa, slice); try buf.append(gpa, '\n'); } /// §3.3 L825, §5.2 — write the content-request card `gimme HASH` into `out` /// (no terminating LF; the caller frames it, matching the other card /// writers). HASH is the artifact hash exactly as stored — 40 or 64 /// lowercase hex — and may not embed Fossil whitespace, which would /// re-tokenize the card on the wire. fn writeGimme(hash: []const u8, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(!containsFossilWhitespace(hash)); std.debug.assert(hash.len <= max_line_len); return try std.fmt.bufPrint(out, "gimme {s}", .{hash}); } /// §3.3 L811, §9.2 L2191-2192 — write the holding-announcement card /// `igot HASH` (public) or `igot HASH 1` (private) into `out`, no /// terminating LF. The trailing `1` is the §9.2 private marker. fn writeIgot(hash: []const u8, private: bool, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(!containsFossilWhitespace(hash)); std.debug.assert(hash.len <= max_line_len); return if (private) try std.fmt.bufPrint(out, "igot {s} 1", .{hash}) else try std.fmt.bufPrint(out, "igot {s}", .{hash}); } /// §7.1 — write the full-content file card `file HASH SIZE\n` immediately /// followed by exactly SIZE payload bytes, appended into `out`. Unlike the /// line writers this emits its own LF (the header/payload separator is /// intrinsic to the card) and grows the buffer, because the payload is /// arbitrary length and NUL-legal (`[]const u8`, no sentinel). SIZE is the /// byte length of `content`. The closed error set carries the alloc failure /// and the (bounds-asserted, so unreached) header-format failure separately. fn writeFile( gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), hash: []const u8, content: []const u8, ) error{ OutOfMemory, NoSpaceLeft }!void { std.debug.assert(!containsFossilWhitespace(hash)); std.debug.assert(hash.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex std.debug.assert(content.len <= reply_body_cap); var header: [128]u8 = undefined; const h = try std.fmt.bufPrint(&header, "file {s} {d}\n", .{ hash, content.len }); try out.appendSlice(gpa, h); try out.appendSlice(gpa, content); } test "sync §3.3 L825: writeGimme emits the canonical gimme card" { var buf: [128]u8 = undefined; const h = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; try std.testing.expectEqualStrings("gimme " ++ h, try writeGimme(h, &buf)); } test "sync §9.2 L2191-2192: writeIgot emits the public and private igot forms" { var buf: [128]u8 = undefined; const h = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; try std.testing.expectEqualStrings("igot " ++ h, try writeIgot(h, false, &buf)); try std.testing.expectEqualStrings("igot " ++ h ++ " 1", try writeIgot(h, true, &buf)); } test "sync §7.1: writeFile emits file HASH SIZE and exactly SIZE payload bytes" { const h = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; // §A.1 L4131-4132, L4153 — alpha.txt is 26 bytes ("...client\n"). const payload = "alpha content from client\n"; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(std.testing.allocator); try writeFile(std.testing.allocator, &out, h, payload); try std.testing.expectEqualStrings("file " ++ h ++ " 26\n" ++ payload, out.items); // Negative space: the payload is raw bytes, so an embedded NUL is carried // verbatim and counted in SIZE (§4 r? — wire data is []const u8, NUL-legal). out.clearRetainingCapacity(); const nul_payload = "a\x00b"; try writeFile(std.testing.allocator, &out, h, nul_payload); try std.testing.expectEqualStrings("file " ++ h ++ " 3\n" ++ nul_payload, out.items); } test "sync §3.3(c) L845-846: buildRequest omits gimme for a private phantom until private sync is enabled" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const h = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15"; const rid = try ms.store().intern(h); // a phantom: content absent var client = Client{ .operation = .sync }; defer client.deinit(std.testing.allocator); // A private `igot HASH 1` (§7.5) would set this; mark it directly here. try client.phantoms.put(std.testing.allocator, rid, .{ .private = true }); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(std.testing.allocator); // Private-content sync disabled (default) ⇒ the private phantom is excluded. const gated = try client.buildRequest(std.testing.allocator, ms.store(), &out); try std.testing.expect(std.mem.indexOf(u8, gated, "gimme " ++ h) == null); // Enabling it makes the same phantom eligible — proving the filter, not an // unrelated omission, is the cause. client.private_granted = true; const opened = try client.buildRequest(std.testing.allocator, ms.store(), &out); try std.testing.expect(std.mem.indexOf(u8, opened, "gimme " ++ h) != null); } // §3.3 r24: a phantom is a hash known with content absent — this is // `ArtifactStore`'s own contract (`content(rid) == null`), not a sync-layer // mechanism. No `isPhantom` wrapper enters this file; the definition is // asserted directly against the store below (store-contract dependency // test, not sync coverage). // §3.3 r36: storing content dephantomizes — again the store's own contract, // asserted directly (store-contract dependency test, not sync coverage). /// §3.2 r22/r23, L455-473 — request-card classification for the ordering /// rule. `content_request` covers clone and gimme (L471); `uvgimme` is /// unversioned and deferred. `send_private`/`login` classify as `.other` /// (L458-463), so the two transport/order exceptions fall out of the /// ordering scan below without special-casing them. pub const RequestCard = enum { client_version, content_request, other }; pub const RequestOrder = enum { ok, missing_client_version, version_after_content_request }; /// §3.2, L455-473 — a request is well-ordered iff it carries a /// client-version pragma and no content-requesting card precedes it. An /// empty body has no client-version card, so it is itself the r22 /// rejection — no length assertion, no panic (an empty request is legal /// input, not a caller bug). pub fn checkRequestOrder(cards: []const RequestCard) RequestOrder { var version_index: ?usize = null; var content_index: ?usize = null; for (cards, 0..) |card, i| { switch (card) { .client_version => if (version_index == null) { version_index = i; }, .content_request => if (content_index == null) { content_index = i; }, .other => {}, } } const result: RequestOrder = if (version_index == null) .missing_client_version else if (content_index != null and content_index.? < version_index.?) .version_after_content_request else .ok; std.debug.assert(result != .ok or (version_index != null and (content_index == null or content_index.? > version_index.?))); return result; } // --------------------------------------------------------------------------- // Section 8 — Clone // --------------------------------------------------------------------------- /// §8.1 L1867-1876 — the sole interoperable clone transfer path this /// document defines. Orthogonal to the release integer carried in /// version pragmas (L1881-1885): the 20000 threshold in `sha3PeerVersionGate` /// names a Fossil release, never this transfer-path version. pub const clone_version: u8 = 3; /// §8.1 L1867 — write `clone VERSION SEQNO`. SEQNO is a blob-table row /// identifier (§8.2 L1889), always non-negative on the request side (a /// negative NEXT received in a reply is fatal before it is ever /// re-sent, per `cloneSeqnoIsFatal`). pub fn writeCloneCard(seqno: u64, out: []u8) std.fmt.BufPrintError![]u8 { const needed = std.fmt.count("clone {d} {d}", .{ clone_version, seqno }); std.debug.assert(needed <= 28); // "clone 3 " + max u64 digits, ceiling std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "clone {d} {d}", .{ clone_version, seqno }); } /// §8.2 L1896-1937 — parse the reply's `clone_seqno NEXT` card. A /// present-but-non-integer NEXT is the sole real parse failure; a /// negative value parses cleanly here and is instead the fatal-cause /// path's job (`cloneSeqnoIsFatal`), not a rejection at this layer. pub fn parseCloneSeqno(tokens: Tokens) error{Malformed}!i64 { if (tokens.len < 2) return error.Malformed; return std.fmt.parseInt(i64, tokens.items[1], 10) catch error.Malformed; } /// §8.2 L1965-1983 loop-tail — the shape of the next request, given /// whether Section 3.7 continues the exchange and the NEXT just /// received. `next > 0` sends another clone card; a live continuation /// with NEXT already exhausted is a cleanup round (no clone card, L1930- /// 1934); no continuation stops. The MUST-NOT-after-0 rule (L1927-1928) /// falls out structurally: `.clone_card` is unreachable once NEXT is 0. pub const NextRequest = enum { clone_card, cleanup_round, stop }; pub fn nextRequestKind(continuing: bool, clone_seqno_next: i64) NextRequest { std.debug.assert(clone_seqno_next >= 0); // negative NEXT aborts before this is reached if (!continuing) return .stop; return if (clone_seqno_next > 0) .clone_card else .cleanup_round; } /// §8.2 L1917-1921 — the server's matching liveness invariant: a /// positive NEXT is only legal when the round it ends also advanced the /// sequence. `Server.emitCloneReply` upholds it structurally (a positive /// NEXT is produced only after a full batch advanced the cursor); this is /// the pure predicate that states it. pub fn cloneServerMustAdvance(clone_seqno_next: i64, unsent_at_or_above_next: bool) bool { std.debug.assert(clone_seqno_next >= 0); return clone_seqno_next > 0 and unsent_at_or_above_next; } /// §8.1/§8.2 — the outcome of classifying a server-received `clone` request. /// `accept` carries the SEQNO (a blob-table RID ≥ 1) the reply pages from; /// `invalid_seqno` is the §8.1 L1369-1376 fatal error; `malformed` is any other /// shape, which §8.1 L1374-1376 explicitly keeps OUT of the fatal path (a plain /// §5.2 bad command instead). const CloneRequestClass = union(enum) { accept: u64, invalid_seqno, malformed }; /// §8.1 L1363-1376 — classify `clone VERSION SEQNO`. The fatal invalid-sequence /// error is reserved for exactly three tokens, an integer VERSION ≥ 2, and a /// digit-only SEQNO whose value is ≤ 0 (digit-only means ≥ 0, so ≤ 0 is exactly /// 0). Acceptance additionally requires the sole interoperable VERSION 3 (L1365) /// and a positive SEQNO. Wrong arity, a noninteger/out-of-scope VERSION, or a /// non-digit SEQNO is `malformed`, never the fatal error (L1374-1376). fn classifyCloneRequest(tokens: Tokens) CloneRequestClass { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .clone); if (tokens.len != 3) return .malformed; const version = std.fmt.parseInt(i64, tokens.items[1], 10) catch return .malformed; if (!isAllDigits(tokens.items[2])) return .malformed; // §8.1 — SEQNO must be digit-only const seqno = std.fmt.parseInt(u64, tokens.items[2], 10) catch return .malformed; if (version >= 2 and seqno == 0) return .invalid_seqno; // §8.1 L1369-1373 if (version == @as(i64, clone_version) and seqno > 0) return .{ .accept = seqno }; // §8.1 L1365 return .malformed; } /// §8.1 L1370 — "digit-only recognition": non-empty and every byte a /// decimal digit, so a leading sign or any non-digit rejects (unlike /// `parseInt`, which would accept a `+`/`-` sign). fn isAllDigits(s: []const u8) bool { if (s.len == 0) return false; for (s) |c| if (c < '0' or c > '9') return false; return true; } /// §7.2 L1139/§8.2 L1384 — append one full-content `cfile HASH USIZE CSIZE\n` /// card followed by exactly CSIZE stored-container bytes. USIZE is the /// uncompressed artifact size; the payload is the §4.1 zlib container /// (`codec.encode`) over `content`, which the peer decodes back to /// `content` verbatim (§7.2 L1145-1146). The artifact hash is over the /// UNCOMPRESSED bytes, so storage-side compression never changes HASH. NUL-legal /// `[]const u8`, no sentinel. Closed error set: allocation, an oversized input /// the container length field cannot frame, and the (bounds-asserted) header /// format failure. fn writeCfile( gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), hash: []const u8, content: []const u8, ) error{ OutOfMemory, InputTooLarge, NoSpaceLeft }!void { std.debug.assert(!containsFossilWhitespace(hash)); std.debug.assert(hash.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex std.debug.assert(content.len <= reply_body_cap); const container = codec.encode(gpa, content, .default) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.InputTooLarge => return error.InputTooLarge, }; defer gpa.free(container); var header: [160]u8 = undefined; const h = try std.fmt.bufPrint(&header, "cfile {s} {d} {d}\n", .{ hash, content.len, container.len }); try out.appendSlice(gpa, h); try out.appendSlice(gpa, container); // §5.1 L877-879 — append one padding LF only when the counted compressed // payload does not itself end in LF, so a strict reference receiver consumes // it as an empty line (see cfilePaddingEnd, which skips under the identical // predicate). Keyed on the container's LAST byte; a zlib stream may embed LF // elsewhere. Omitted when the payload self-terminates in LF, so no receiver // over-skips the next card. if (container.len == 0 or container[container.len - 1] != '\n') { try out.append(gpa, '\n'); } } /// §4.1 L858-859/§7.2 L1145-1146 — append one full-content cfile card whose /// payload is the artifact's ALREADY-STORED §4.1 `container`, retransmitted /// verbatim, with no re-compression: the stored blob "uses the same /// byte-identical container and [is] retransmitted verbatim." USIZE is read /// from the container's own first four bytes (§4.1 — the container's big-endian /// uncompressed length); CSIZE is the container's byte length. The card is the /// full form `cfile HASH USIZE CSIZE` — a delta-stored artifact has no verbatim /// wire form here and is served through the `writeCfile` reconstruct path /// instead. Applies the identical §5.1 trailing-LF padding rule as `writeCfile`, /// keyed on the payload's last byte. NUL-legal `[]const u8`, no sentinel. Closed /// error set: allocation and the (bounds-asserted) header format failure. fn writeCfileVerbatim( gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), hash: []const u8, container: []const u8, ) error{ OutOfMemory, NoSpaceLeft }!void { std.debug.assert(!containsFossilWhitespace(hash)); std.debug.assert(hash.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex std.debug.assert(container.len >= 4); // §4.1 — the container carries its 4-byte length prefix const usize_bytes = std.mem.readInt(u32, container[0..4], .big); // §4.1 — USIZE is the container's own prefix var header: [128]u8 = undefined; const h = try std.fmt.bufPrint(&header, "cfile {s} {d} {d}\n", .{ hash, usize_bytes, container.len }); try out.appendSlice(gpa, h); try out.appendSlice(gpa, container); // §5.1 L877-879 — one padding LF iff the counted payload does not itself // end in LF, identical to `writeCfile` (round-trip with `cfilePaddingEnd`). if (container.len == 0 or container[container.len - 1] != '\n') { try out.append(gpa, '\n'); } } /// §7.2 L1140/L1142-1144 — append one DELTA-form cfile card /// `cfile HASH DELTASRC USIZE CSIZE` whose payload is the artifact's /// ALREADY-STORED §4.1 container of a [FossilDelta] against DELTASRC, /// retransmitted verbatim with no re-compression. /// /// USIZE is `target_size`, the sender's full RECONSTRUCTED artifact size (read /// from the delta's own header, delta §3.2 — "the length in bytes of the /// reconstructed target"). It is deliberately NOT the container's 4-byte prefix: /// that prefix is the RAW DELTA's length (§4.1 framing of the delta payload), /// which is exactly the value the deleted #1 code wrongly emitted. §7.2 /// L1142-1144: "USIZE is the sender's full reconstructed artifact size … in /// delta form it is not delta size." CSIZE is the container's byte length; it /// alone frames the payload. Same §5.1 trailing-LF padding rule as the full /// forms. NUL-legal `[]const u8`, no sentinel. Closed error set: allocation and /// the (bounds-asserted) header format failure. fn writeCfileVerbatimDelta( gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), hash: []const u8, deltasrc: []const u8, target_size: u64, container: []const u8, ) error{ OutOfMemory, NoSpaceLeft }!void { std.debug.assert(!containsFossilWhitespace(hash)); std.debug.assert(!containsFossilWhitespace(deltasrc)); std.debug.assert(hash.len <= 64 and deltasrc.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex std.debug.assert(container.len >= 4); // §4.1 — the container carries its 4-byte length prefix // §7.2 — USIZE is the reconstructed size and MUST fit the u32 USIZE token // (the receiver parses it as u32); the delta header enforces the same cap. std.debug.assert(target_size <= std.math.maxInt(u32)); // §7.2 L1142-1144 — emit `target_size` as USIZE, NEVER the container's // 4-byte prefix; reading that prefix here would reintroduce the #1 defect. var header: [192]u8 = undefined; const h = try std.fmt.bufPrint(&header, "cfile {s} {s} {d} {d}\n", .{ hash, deltasrc, target_size, container.len }); try out.appendSlice(gpa, h); try out.appendSlice(gpa, container); // §5.1 L877-879 — one padding LF iff the counted payload does not itself // end in LF, identical to the full forms (round-trip with `cfilePaddingEnd`). if (container.len == 0 or container[container.len - 1] != '\n') { try out.append(gpa, '\n'); } } /// §8.2 L1387 — write `clone_seqno NEXT`; a canonical sender uses decimal NEXT /// (L1388). NEXT zero signals exhaustion; a positive NEXT is the next RID. fn writeCloneSeqno(next: u64, out: []u8) std.fmt.BufPrintError![]u8 { const needed = std.fmt.count("clone_seqno {d}", .{next}); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "clone_seqno {d}", .{next}); } // --- §8 clone: inline tests reaching file-private decls (per the project's // kind-separation rule: the public `Server.respond` behaviour is tested from // the sibling `sync_test.zig`; these reach `classifyCloneRequest`, `writeCfile`, // and `clone_cfile_batch_max`, which are private to this file). Expected values // come from the sync RFC §8 text only, never from observing Fossil. test "sync §8.1: classifyCloneRequest accepts clone 3 SEQNO>0, and rejects only VERSION>=2 SEQNO 0" { const cls = struct { fn f(line: []const u8) CloneRequestClass { return classifyCloneRequest(classifyCard(line).tokens); } }.f; // §8.1 L1365 — accept requires the sole interoperable VERSION 3 and a // positive, digit-only SEQNO; the SEQNO becomes the reply's paging cursor. try std.testing.expectEqual(@as(u64, 1), cls("clone 3 1").accept); try std.testing.expectEqual(@as(u64, 7), cls("clone 3 7").accept); // §8.1 L1369-1373 — the ONLY fatal shape: three tokens, VERSION>=2, and a // digit-only SEQNO whose value is <= 0 (digit-only => exactly 0). try std.testing.expect(cls("clone 3 0") == .invalid_seqno); try std.testing.expect(cls("clone 2 0") == .invalid_seqno); } test "sync §8.1 L1374-1376: the invalid-sequence carve-out — wrong shapes are bad command, never fatal" { const cls = struct { fn f(line: []const u8) CloneRequestClass { return classifyCloneRequest(classifyCard(line).tokens); } }.f; try std.testing.expect(cls("clone 3") == .malformed); // wrong arity try std.testing.expect(cls("clone x 5") == .malformed); // noninteger VERSION try std.testing.expect(cls("clone 3 -1") == .malformed); // SEQNO not digit-only try std.testing.expect(cls("clone 3 5z") == .malformed); // SEQNO not digit-only try std.testing.expect(cls("clone 1 5") == .malformed); // out-of-scope VERSION < 2, positive SEQNO try std.testing.expect(cls("clone 2 5") == .malformed); // VERSION 2 out of scope (only 3 interoperable) // §8.1 L1374-1376 — a noninteger VERSION with SEQNO 0 is NOT the fatal error. try std.testing.expect(cls("clone x 0") == .malformed); } test "sync §8.1 L1370: isAllDigits requires a non-empty all-decimal string" { try std.testing.expect(isAllDigits("0")); try std.testing.expect(isAllDigits("2048")); try std.testing.expect(!isAllDigits("")); // empty is not digit-only try std.testing.expect(!isAllDigits("+5")); // a sign is not a digit try std.testing.expect(!isAllDigits("-5")); try std.testing.expect(!isAllDigits("1a")); } test "sync §8.2 L1387: writeCloneSeqno emits decimal NEXT, 0 signalling exhaustion" { var buf: [64]u8 = undefined; try std.testing.expectEqualStrings("clone_seqno 0", try writeCloneSeqno(0, &buf)); try std.testing.expectEqualStrings("clone_seqno 42", try writeCloneSeqno(42, &buf)); } test "sync §7.2 L1139-1146: writeCfile frames cfile HASH USIZE CSIZE and a container decoding back to content" { const gpa = std.testing.allocator; const hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; const content = "hello from zit over http\n"; // §A-style content, NUL-free here var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfile(gpa, &out, hash, content); const nl = std.mem.indexOfScalar(u8, out.items, '\n').?; const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens); try std.testing.expectEqualStrings(hash, cc.hash); try std.testing.expect(cc.deltasrc == null); // §7.2 L1142-1143 — USIZE is the uncompressed size; CSIZE frames the payload. try std.testing.expectEqual(@as(u32, content.len), cc.uncompressed_size); // §5.1 — the payload is exactly CSIZE bytes; any §5.1 padding LF that // writeCfile appends past it is not part of the counted payload. const payload = out.items[nl + 1 .. nl + 1 + cc.compressed_size]; try std.testing.expectEqual(@as(usize, cc.compressed_size), payload.len); const decoded = try codec.decode(gpa, payload, reply_body_cap); defer gpa.free(decoded); try std.testing.expectEqualStrings(content, decoded); // §7.2 L1145 — reconstructed verbatim } test "sync §7.2 L1142-1144: served delta cfile USIZE is the reconstructed artifact size, not the delta length" { // THE regression guard for the #1 defect: the deleted #1 code wrote the // container's 4-byte prefix (the RAW DELTA length) as USIZE. This proves // writeCfileVerbatimDelta emits the reconstructed target size instead. const gpa = std.testing.allocator; const base = "a fairly long base artifact whose bytes are largely shared with the target so the delta is dominated by one big copy segment here"; const target = "a fairly long base artifact whose bytes are largely shared with the target so the delta is dominated by one big copy segment here!!"; const raw = try delta.create(gpa, base, target); defer gpa.free(raw); const container = try codec.encode(gpa, raw, .default); defer gpa.free(container); const target_size = try delta.targetSize(raw); try std.testing.expectEqual(@as(usize, target.len), target_size); // The three candidate USIZE values are all distinct, so the guard is meaningful. const container_prefix = std.mem.readInt(u32, container[0..4], .big); // = raw delta length (§4.1 framing) try std.testing.expectEqual(@as(u32, @intCast(raw.len)), container_prefix); try std.testing.expect(raw.len != target.len); try std.testing.expect(container.len != target.len); const hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; const deltasrc = "d1e5e4e40a2f3f6b1c9a8b7c6d5e4f30211122233344455566677788899900aa"; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfileVerbatimDelta(gpa, &out, hash, deltasrc, target_size, container); const nl = std.mem.indexOfScalar(u8, out.items, '\n').?; const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens); try std.testing.expectEqualStrings(hash, cc.hash); try std.testing.expect(cc.deltasrc != null); try std.testing.expectEqualStrings(deltasrc, cc.deltasrc.?); // §7.2 L1142-1144 — USIZE is the reconstructed target size, NEVER the delta // length and NEVER the container's 4-byte prefix (the raw delta length). try std.testing.expectEqual(@as(u32, target.len), cc.uncompressed_size); try std.testing.expect(cc.uncompressed_size != container_prefix); try std.testing.expect(cc.uncompressed_size != @as(u32, @intCast(raw.len))); // CSIZE frames the payload: exactly the container byte length. try std.testing.expectEqual(@as(u32, @intCast(container.len)), cc.compressed_size); } test "sync §7.2 L1145-1146: served delta cfile payload is the stored delta container, retransmitted verbatim, and round-trips" { const gpa = std.testing.allocator; const base = "verbatim delta serve: a base whose long shared run makes the delta a small copy-dominated payload, distinct from the reconstructed size"; const target = "verbatim delta serve: a base whose long shared run makes the delta a small copy-dominated payload, distinct from the reconstructed size!"; const raw = try delta.create(gpa, base, target); defer gpa.free(raw); const container = try codec.encode(gpa, raw, .default); defer gpa.free(container); const target_size = try delta.targetSize(raw); const hash = "1111111111111111111111111111111111111111111111111111111111111111"; const deltasrc = "2222222222222222222222222222222222222222222222222222222222222222"; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfileVerbatimDelta(gpa, &out, hash, deltasrc, target_size, container); const nl = std.mem.indexOfScalar(u8, out.items, '\n').?; const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens); // §7.2 L1145-1146 — the counted payload equals the stored container byte-for-byte. const payload = out.items[nl + 1 .. nl + 1 + cc.compressed_size]; try std.testing.expectEqualSlices(u8, container, payload); // §7.2/§7.4 — the container decodes to the raw delta, which reconstructs the // original target against its basis (client-side reconstruction). const decoded = try codec.decode(gpa, payload, reply_body_cap); defer gpa.free(decoded); const recon = try delta.apply(gpa, base, decoded); defer gpa.free(recon); try std.testing.expectEqualStrings(target, recon); } test "sync §5.1: cfilePaddingEnd consumes one padding LF iff the payload does not end in LF" { // A payload NOT ending in LF is followed on the wire by one padding LF, then // the next card: the resume offset skips exactly that padding LF. const with_pad = "ABCD\nnext"; // payload "ABCD" (last byte 'D'), padding LF at index 4 const p1 = try consumeContentPayload(with_pad, 0, 4); try std.testing.expectEqual(@as(usize, 5), cfilePaddingEnd(with_pad, p1)); // skipped the padding LF at index 4 // A payload ending in LF carries NO padding; the resume offset is unchanged, // so a following byte (real card or empty line) is left for the loop. const no_pad = "AB\nX"; // payload "AB\n" ends in LF const p2 = try consumeContentPayload(no_pad, 0, 3); try std.testing.expectEqual(@as(usize, 3), cfilePaddingEnd(no_pad, p2)); // no over-skip: 'X' left intact // Payload ending in LF immediately before a bare empty line: the empty line // is NOT consumed (negative space — it is the loop's to reject), not padding. const lf_then_empty = "AB\n\n"; const p3 = try consumeContentPayload(lf_then_empty, 0, 3); try std.testing.expectEqual(@as(usize, 3), cfilePaddingEnd(lf_then_empty, p3)); // Bounded: a payload not ending in LF that is the final bytes of the body // (no padding on the wire) never advances past the end. const truncated = "ABCD"; const p4 = try consumeContentPayload(truncated, 0, 4); try std.testing.expectEqual(@as(usize, 4), cfilePaddingEnd(truncated, p4)); } test "sync §5.1: writeCfile appends the trailing-LF padding iff the container does not end in LF" { const gpa = std.testing.allocator; const hash = "b" ** 40; const content = "some artifact bytes for the padding predicate"; const container = try codec.encode(gpa, content, .default); defer gpa.free(container); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfile(gpa, &out, hash, content); const header = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ hash, content.len, container.len }); defer gpa.free(header); const framed = header.len + container.len; // header + counted CSIZE payload // §5.1 — padding LF present exactly when the container does not end in LF. const padded = container.len == 0 or container[container.len - 1] != '\n'; if (padded) { try std.testing.expectEqual(framed + 1, out.items.len); try std.testing.expectEqual(@as(u8, '\n'), out.items[out.items.len - 1]); } else { try std.testing.expectEqual(framed, out.items.len); } } test "sync §7.2 L1145-1146: writeCfileVerbatim retransmits the stored container byte-for-byte" { const gpa = std.testing.allocator; const hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; const content = "verbatim serve: the stored container is the wire payload\n"; const container = try codec.encode(gpa, content, .default); defer gpa.free(container); // Full form (cfile HASH USIZE CSIZE): USIZE is read from the container's own // §4.1 4-byte prefix, CSIZE is the container length, and the payload is the // stored container verbatim — never re-encoded. { var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfileVerbatim(gpa, &out, hash, container); const nl = std.mem.indexOfScalar(u8, out.items, '\n').?; const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens); try std.testing.expectEqualStrings(hash, cc.hash); try std.testing.expect(cc.deltasrc == null); // §7.2 — verbatim serving is full-form only try std.testing.expectEqual(@as(u32, content.len), cc.uncompressed_size); // §4.1 — USIZE == the container prefix try std.testing.expectEqual(@as(u32, @intCast(container.len)), cc.compressed_size); const payload = out.items[nl + 1 .. nl + 1 + cc.compressed_size]; try std.testing.expectEqualSlices(u8, container, payload); // §7.2 L1145 — byte-for-byte the stored container } } test "sync §5.1: a verbatim-served cfile applies the trailing padding-LF rule" { const gpa = std.testing.allocator; const hash = "c" ** 40; // The predicate keys on the payload's LAST byte only (§5.1 L877-879); the // container is opaque to the writer, so synthetic payloads carrying the §4.1 // 4-byte length prefix (value 1) exercise both arms without a real stream. const Case = struct { container: []const u8, want_pad: bool }; const cases = [_]Case{ .{ .container = &[_]u8{ 0, 0, 0, 1, 'X', '\n' }, .want_pad = false }, // ends in LF: no padding .{ .container = &[_]u8{ 0, 0, 0, 1, 'X', 'Y' }, .want_pad = true }, // no trailing LF: one padding LF }; for (cases) |c| { var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfileVerbatim(gpa, &out, hash, c.container); const nl = std.mem.indexOfScalar(u8, out.items, '\n').?; const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens); const payload = try consumeContentPayload(out.items, nl + 1, cc.compressed_size); try std.testing.expectEqualSlices(u8, c.container, payload.bytes); // verbatim const resume_at = cfilePaddingEnd(out.items, payload); if (c.want_pad) { try std.testing.expectEqual(payload.next + 1, resume_at); // §5.1 — one padding LF consumed try std.testing.expectEqual(out.items.len, resume_at); try std.testing.expectEqual(@as(u8, '\n'), out.items[out.items.len - 1]); } else { try std.testing.expectEqual(payload.next, resume_at); // no over-skip try std.testing.expectEqual(out.items.len, payload.next); // body ends exactly at the payload } } } /// Test walker: return the CSIZE-framed payload of the first `cfile` card in a /// reply body whose HASH matches, advancing past each cfile's §5.1 padding. fn findCfilePayload(body: []const u8, hash: []const u8) !([]const u8) { var cursor: usize = 0; while (cursor < body.len) { const line = try readLine(body, cursor); const cls = classifyCard(line.bytes); if (cls.operator == .cfile) { const cc = try parseCfileCard(cls.tokens); const payload = try consumeContentPayload(body, line.next, cc.compressed_size); if (std.mem.eql(u8, cc.hash, hash)) return payload.bytes; cursor = cfilePaddingEnd(body, payload); } else { cursor = line.next; } } return error.NotFound; } test "sync §8.2: a clone reply mixing raw and zlib stored artifacts serves both paths" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); // rid 1: a RAW entry (put) → no stored wire → the fallback compresses it on // serve. rid 2: a ZLIB entry (putWire) → served as its stored container. const hash_a = "a" ** 64; const content_a = "raw-stored artifact: served by the compress fallback\n"; _ = try ms.store().put(hash_a, content_a); const hash_b = "b" ** 64; const content_b = "zlib-stored artifact: served as the stored container\n"; const container_b = try codec.encode(gpa, content_b, .default); defer gpa.free(container_b); _ = try ms.putWire(hash_b, container_b); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", }; var scan: RequestScan = .{}; defer scan.deinit(gpa); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); var line_buf: [256]u8 = undefined; // §8.2 — clone reply from SEQNO 1: both artifacts as cfile cards, RID order. try server.emitCloneReply(gpa, ms.store(), &scan, &out, &line_buf, 1); const a = try findCfilePayload(out.items, hash_a); const b = try findCfilePayload(out.items, hash_b); // §7.2 L1145 — the zlib artifact is retransmitted byte-for-byte. try std.testing.expectEqualSlices(u8, container_b, b); // Both payloads decode back to their content: the raw one through the // on-the-fly fallback, the zlib one through its verbatim container. const dec_a = try codec.decode(gpa, a, reply_body_cap); defer gpa.free(dec_a); try std.testing.expectEqualStrings(content_a, dec_a); const dec_b = try codec.decode(gpa, b, reply_body_cap); defer gpa.free(dec_b); try std.testing.expectEqualStrings(content_b, dec_b); } test "sync §5.1: writeCfile→consumeReply round-trips consecutive cfiles with mixed payload endings" { const gpa = std.testing.allocator; // Two back-to-back cfiles emitted by the writer are parsed and stored by the // reader through the §5.1 padding on each boundary — the sender-emits and // receiver-consumes halves are mutually consistent (no over/under-skip). const hash_a = "c" ** 40; const hash_b = "d" ** 40; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try writeCfile(gpa, &out, hash_a, "first artifact content over the wire"); try writeCfile(gpa, &out, hash_b, "second artifact content, distinct bytes"); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), out.items); try std.testing.expect(client.state != .aborted); try std.testing.expect(ms.store().lookup(hash_a) != null); // both cfiles crossed the padded boundaries try std.testing.expect(ms.store().lookup(hash_b) != null); } // Test-only reply walker: iterate a reply's cards, framing past a file/cfile // payload so a binary payload byte is never miscounted as a card boundary. const CloneReplyIter = struct { body: []const u8, pos: usize = 0, fn next(self: *CloneReplyIter) ?CardClass { while (self.pos < self.body.len) { const line = readLine(self.body, self.pos) catch return null; self.pos = line.next; if (isCommentLine(line.bytes)) continue; const cc = classifyCard(line.bytes); if (cc.operator) |op| switch (op) { .cfile => { const c = parseCfileCard(cc.tokens) catch return cc; const payload = consumeContentPayload(self.body, self.pos, c.compressed_size) catch return cc; self.pos = cfilePaddingEnd(self.body, payload); // §5.1 — skip the padding LF like the receiver }, .file => { const c = parseFileCard(cc.tokens) catch return cc; self.pos = (consumeContentPayload(self.body, self.pos, c.size) catch return cc).next; }, else => {}, }; return cc; } return null; } }; test "sync §8.2 L1384-1413: emitCloneReply pages a store larger than one batch across clone_seqno cursors" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); // Dense RIDs 1..=total, more artifacts than one batch carries (§8.2 L1384). const total = clone_cfile_batch_max + 3; var i: usize = 0; while (i < total) : (i += 1) { var name: [64]u8 = undefined; _ = std.fmt.bufPrint(&name, "{x:0>64}", .{i}) catch unreachable; _ = try ms.store().put(&name, "x"); } const server = Server{ .version = .{ .version = 1 }, .self_servercode = "s", .project_code = "p" }; // Batch 1 from SEQNO 1: exactly clone_cfile_batch_max cfiles, then a // positive NEXT = cap+1 (the cursor advanced past the sent batch). var out1: std.ArrayListUnmanaged(u8) = .empty; defer out1.deinit(gpa); const r1 = try server.respond(gpa, ms.store(), "pragma client-version 1\nclone 3 1\n", &out1); var it1 = CloneReplyIter{ .body = r1 }; var cfiles1: usize = 0; var next1: ?i64 = null; while (it1.next()) |cc| { switch (cc.operator.?) { .cfile => cfiles1 += 1, .clone_seqno => next1 = try parseCloneSeqno(cc.tokens), else => {}, } } try std.testing.expectEqual(clone_cfile_batch_max, cfiles1); try std.testing.expectEqual(@as(i64, @intCast(clone_cfile_batch_max + 1)), next1.?); // Batch 2 from NEXT: the remaining 3 cfiles and clone_seqno 0 (exhausted). var out2: std.ArrayListUnmanaged(u8) = .empty; defer out2.deinit(gpa); var req2: [48]u8 = undefined; const line2 = try std.fmt.bufPrint(&req2, "pragma client-version 1\nclone 3 {d}\n", .{clone_cfile_batch_max + 1}); const r2 = try server.respond(gpa, ms.store(), line2, &out2); var it2 = CloneReplyIter{ .body = r2 }; var cfiles2: usize = 0; var next2: ?i64 = null; while (it2.next()) |cc| { switch (cc.operator.?) { .cfile => cfiles2 += 1, .clone_seqno => next2 = try parseCloneSeqno(cc.tokens), else => {}, } } try std.testing.expectEqual(@as(usize, 3), cfiles2); try std.testing.expectEqual(@as(i64, 0), next2.?); // §8.2 L1387 — NEXT zero means exhausted } // --------------------------------------------------------------------------- // Section 9 — Synchronisation Strategies // --------------------------------------------------------------------------- /// §9.1 L2035-2037 — push and pull share one wire shape (`KEYWORD /// SERVERCODE PROJECTCODE`); one writer keyed on direction avoids /// duplicating the format string. pub const PushPull = enum { push, pull }; /// §9.1 L2035-2037 — write `push SERVERCODE PROJECTCODE` or `pull /// SERVERCODE PROJECTCODE`. Neither field may embed Fossil whitespace — /// that would silently re-tokenize the card on the wire. pub fn writePushPullCard(dir: PushPull, servercode: []const u8, projectcode: []const u8, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(!containsFossilWhitespace(servercode) and !containsFossilWhitespace(projectcode)); std.debug.assert(servercode.len <= max_line_len and projectcode.len <= max_line_len); const keyword = switch (dir) { .push => "push", .pull => "pull", }; const needed = std.fmt.count("{s} {s} {s}", .{ keyword, servercode, projectcode }); std.debug.assert(out.len >= needed); return try std.fmt.bufPrint(out, "{s} {s} {s}", .{ keyword, servercode, projectcode }); } /// §9.1/§8.3 L1999-2002 — a parsed `push|pull SERVERCODE PROJECTCODE` /// card; PROJECTCODE is also how a clone client, unauthenticated on /// round 0, first learns the peer's project code (§6.3). pub const PushPullCard = struct { servercode: []const u8, projectcode: []const u8 }; /// §9.1 L2035-2037 — positional parse; the caller already resolved the /// operator (push vs. pull) via `classifyCard`/`parseOperator`, so one /// parser serves both keywords, mirroring `parseReqconfigName`. pub fn parsePushPull(tokens: Tokens) ?PushPullCard { std.debug.assert(tokens.len >= 1); const op = parseOperator(tokens.items[0]); std.debug.assert(op == .push or op == .pull); if (tokens.len < 3) return null; return .{ .servercode = tokens.items[1], .projectcode = tokens.items[2] }; } /// §9.1 L2039-2041 — the server's project-code check: a mismatch is /// fatal, never a silent proceed. pub const SyncProjectCode = enum { proceed, refuse_fatal }; pub fn serverSyncProjectCodeDecision(stored: []const u8, received: []const u8) SyncProjectCode { std.debug.assert(stored.len <= max_line_len and received.len <= max_line_len); return if (std.mem.eql(u8, stored, received)) .proceed else .refuse_fatal; } /// §9.2 L2199-2202, §10.1 L2308-2312 (DATE is YYYYMMDD) — the private- /// igot suppression threshold as an integer. pub const private_igot_min_peer_date: u32 = 20200413; /// §9.2 L2199-2202 — whether a private artifact's igot announcement is /// suppressed for the recorded peer. `null` (no recorded date) does not /// satisfy "recorded date is before 2020-04-13" -- the condition needs a /// date to test, so an unrecorded peer announces normally. pub const PrivateIgotAction = enum { omit, announce_private }; pub fn privateIgotAction(peer_date: ?u32) PrivateIgotAction { comptime std.debug.assert(private_igot_min_peer_date == 20200413); const date = peer_date orelse return .announce_private; return if (date < private_igot_min_peer_date) .omit else .announce_private; } // --------------------------------------------------------------------------- // Section 4 — Transport // --------------------------------------------------------------------------- /// §4.1 L1113-1187 — the three protocol-defined Content-Type values. pub const MediaType = enum { x_fossil, x_fossil_debug, x_fossil_uncompressed }; /// §4.1 per-media-type facts, one row per Content-Type. Callers read a /// field directly rather than going through a per-fact accessor. pub const MediaTypeProps = struct { /// The literal Content-Type wire string. wire: []const u8, /// r15 true; r16/r17 false — is the outer message body zlib-wrapped? outer_compressed: bool, /// §4 r2/r3 — may a request arrive with this Content-Type? valid_request_type: bool, /// r17 — used only for a clone-v3 reply, never a request. clone_reply_only: bool, /// r16 — x-fossil-debug is diagnostic-only, not for production sync. production_allowed: bool, }; pub const media_props = std.EnumArray(MediaType, MediaTypeProps).init(.{ .x_fossil = .{ .wire = "application/x-fossil", .outer_compressed = true, .valid_request_type = true, .clone_reply_only = false, .production_allowed = true }, .x_fossil_debug = .{ .wire = "application/x-fossil-debug", .outer_compressed = false, .valid_request_type = true, .clone_reply_only = false, .production_allowed = false }, .x_fossil_uncompressed = .{ .wire = "application/x-fossil-uncompressed", .outer_compressed = false, .valid_request_type = false, .clone_reply_only = true, .production_allowed = true }, }); /// §4 r2/r3 — resolve a Content-Type string against the table; null when /// it names none of the three protocol media types. pub fn parseMediaType(s: []const u8) ?MediaType { for (std.meta.tags(MediaType)) |v| { if (std.mem.eql(u8, s, media_props.get(v).wire)) return v; } return null; } /// §4 — the four ways a sync message reaches the wire; only http/https /// carry redirects (r13). pub const Transport = enum { http, https, ssh, file }; /// §4 L1110-1113 (r1) — the request-target is the repository URL's path, /// unchanged; an empty path uses "/". Total and allocation-free: the /// result is always a view into `repo_path` or the "/" literal, so the /// client structurally cannot append a path component (never /xfer). pub fn syncRequestTarget(repo_path: []const u8) []const u8 { return if (repo_path.len == 0) "/" else repo_path; } // §4 L1139-1141 (r6, DEFERRED): the request Content-Length is the exact // byte count of the payload that follows the header block — a bytes-not- // cards HTTP-header fact the CLI request builder computes when it emits // the request; there is no computation to perform or test at this layer. /// §4 L1128-1133 (r4) — the sole protocol-defined nonstandard header. pub const ssh_transport_header: []const u8 = "X-Fossil-Transport: SSH"; /// §4 L1128-1133 (r4) — sent only when the transport is SSH. pub fn needsSshTransportHeader(t: Transport) bool { return switch (t) { .ssh => true, .http, .https, .file => false, }; } /// §4 L1135-1139 (r5) outcome: a POST is processed as sync cards; any /// other method is never processed and is redirected home instead. pub const MethodDisposition = enum { process, redirect_home }; /// §4 L1135-1139 (r5) — a server MUST NOT process sync cards from a /// non-POST request; it redirects such a request to the repository home /// page instead. pub fn serverMethodDisposition(is_post: bool) MethodDisposition { return if (is_post) .process else .redirect_home; } /// §4 L1143-1149 (r7/r8) — how the reply body is framed: an exact byte /// count from Content-Length, or read-until-close when the reply carries /// none. pub const ReplyFraming = union(enum) { exact: usize, until_close }; /// §4 L1143-1149 (r7/r8). pub fn replyFraming(content_length: ?usize) ReplyFraming { return if (content_length) |n| .{ .exact = n } else .until_close; } /// §4 L1146-1149 (r8) — the hard cap, in bytes, on a Content-Length-less /// reply body (512 MiB). pub const reply_body_cap: usize = 536_870_912; /// §4 L1148-1149 (r9) — a reply that would exceed `reply_body_cap` MUST /// be rejected rather than buffered without bound. pub fn replyCapCheck(len: usize) error{ReplyTooLarge}!void { if (len > reply_body_cap) return error.ReplyTooLarge; } /// §4 L1151-1153 (r10) — at most this many redirects are followed for a /// single exchange. pub const max_redirects: usize = 19; /// §4 L1151-1154 (r10/r11) — `followed` is the count already consumed by /// this exchange; the twentieth attempt (`followed == max_redirects`) /// aborts with a fatal redirect-limit error rather than being followed. pub fn redirectAllowed(followed: usize) error{RedirectLimit}!void { if (followed >= max_redirects) return error.RedirectLimit; } /// §4 L1154-1156 (r12) — a redirect MUST NOT downgrade an HTTPS exchange /// to plaintext HTTP. /// /// §12.2 L2721-2734 restates the surrounding transport posture, none of it /// enforceable by a sans-IO format library that holds no socket: /// confidentiality, server authentication, and replay protection of /// credential-bearing traffic are the transport layer's responsibility /// (§12.2-r1 L2721-2723); a deployment carrying such traffic MUST use HTTPS, /// SSH, or an equivalent protected transport (§12.2-r2 L2723-2727) and SHOULD /// NOT use plaintext HTTP in production (§12.2-r3 L2727-2729). Refusing an /// HTTPS->HTTP redirect is this library's sole in-boundary contribution; the /// closing §12.2 observation (L2729-2731 — an intermediary reading a private /// session reads the payloads) is non-normative rationale, no test. pub fn checkRedirectDowngrade(from: Transport, to: Transport) error{InsecureRedirect}!void { if (from == .https and to == .http) return error.InsecureRedirect; } /// §4 L1156-1157 (r13) — SSH and local-file transports never follow a /// redirect; there is no HTTP exchange for a redirect to occur within. pub fn redirectAllowedForTransport(t: Transport) bool { return switch (t) { .http, .https => true, .ssh, .file => false, }; } /// §4 L1157-1165 — when following a redirect, strip a trailing "/xfer" /// path segment from the target Location, repeating while one remains, /// then use the (possibly shortened) Location verbatim as the new base. /// No other path transformation; the original repository URL's path is /// never consulted. Matched as a case-sensitive literal, exactly as /// spelled in the RFC text. Total and allocation-free: returns a /// subslice of `location`. Stripping is confined to the path component, /// so an authority named "xfer" (e.g. https://xfer) is left intact. pub fn stripRedirectXfer(location: []const u8) []const u8 { const scheme_sep = "://"; const path_start = if (std.mem.indexOf(u8, location, scheme_sep)) |auth| std.mem.indexOfScalarPos(u8, location, auth + scheme_sep.len, '/') orelse location.len else 0; const xfer = "/xfer"; var end: usize = location.len; // Bounded: each iteration removes exactly xfer.len (5) bytes and end // is floored at path_start, so this runs at most // (location.len - path_start) / 5 times. while (end >= path_start + xfer.len and std.mem.eql(u8, location[end - xfer.len .. end], xfer)) { end -= xfer.len; } std.debug.assert(end >= path_start and end <= location.len); // Negative space: the fixpoint — the returned slice does not end in // a strippable "/xfer" (this is exactly the loop's exit condition). std.debug.assert(!(end >= path_start + xfer.len and std.mem.eql(u8, location[end - xfer.len .. end], xfer))); return location[0..end]; } // --------------------------------------------------------------------------- // Section 4.1 — Message Compression: the zlib container // --------------------------------------------------------------------------- // The §4.1 container codec is the leaf `codec` module (`container.zig`), // imported above and shared verbatim by `logstore` and `repostate`. Its // public surface is `codec.encode`/`codec.decode` with `codec.Level` and // the `codec.EncodeError`/`codec.DecodeError` sets. // --------------------------------------------------------------------------- // Section 4.2 — Round Structure // --------------------------------------------------------------------------- /// §4.2 L1212-1216 (r22/r23) — the outcome of probing one more offset /// into a reply body: another card follows, the body is exhausted (a /// clean round end, not an error), or a partial trailing line remains. pub const ReplyBodyStep = enum { card_ready, round_complete, truncated }; /// §4.2 L1212-1216 — a round is one POST/response pair ending at body /// exhaustion (r22); exhaustion at a card boundary is not an error /// (r23), since there is no terminal card in the protocol. pub fn replyBodyStep(body: []const u8, offset: usize) ReplyBodyStep { std.debug.assert(offset <= body.len); if (offset == body.len) return .round_complete; _ = readLine(body, offset) catch return .truncated; return .card_ready; } // --------------------------------------------------------------------------- // Section 6 — Authentication // --------------------------------------------------------------------------- /// §6 — 40 lowercase-hex ASCII bytes; every digest this section produces. pub const HashHex = [40]u8; /// §6 — the one digest->hex conversion site every §6 formula funnels /// through, so a hex-case or width bug can only ever be fixed in one place. fn sha1FinalHex(digest: [20]u8) HashHex { return std.fmt.bytesToHex(digest, .lower); } /// §6 — SHA-1 [RFC3174] of `parts` concatenated with no separator, as 40 /// lowercase hex. The sole hashing funnel §6.2/§6.3/§6.4 share; a part /// boundary never enters the digest, so callers own their own separators. fn sha1HexOf(parts: []const []const u8) HashHex { var hasher = std.crypto.hash.Sha1.init(.{}); for (parts) |p| hasher.update(p); var digest: [20]u8 = undefined; hasher.final(&digest); return sha1FinalHex(digest); } /// §6.2 — NONCE = SHA1(body bytes after the login-card LF). `body_tail` /// inherits the §4 r8 reply-body bound since it is itself a message body. pub fn computeNonce(body_tail: []const u8) HashHex { std.debug.assert(body_tail.len <= reply_body_cap); return sha1HexOf(&.{body_tail}); } /// §9.1/§12 (ABNF `projectcode = artifact-hash`) — derive a repository /// project code from a per-repo entropy string. Pure: a SHA-1 over the /// parts rendered as 40 lowercase hex — an artifact-hash-shaped identifier, /// distinct for a distinct entropy string, with no ambient RNG (the /// determinism seam; the value replays from its seed). The RFC pins only /// the wire form (an artifact hash), not a generation rule, so the CLI /// seeds this with the repository's absolute path to give each repository a /// stable, distinct code; the value is opaque and never re-derived or /// validated as a content hash. pub fn deriveProjectCode(entropy_parts: []const []const u8) HashHex { return sha1HexOf(entropy_parts); } /// §6.3 — shared-secret = SHA1(project-code "/" login "/" password). pub fn deriveSharedSecret(project_code: []const u8, login: []const u8, password: []const u8) HashHex { std.debug.assert(login.len > 0 and !containsFossilWhitespace(login)); std.debug.assert(project_code.len <= max_line_len); return sha1HexOf(&.{ project_code, "/", login, "/", password }); } /// §6.4 — SIGNATURE = SHA1(NONCE-hex || secret), an 80-byte input when /// `secret` is itself 40-hex. `secret` is either the 40-hex shared-secret /// (client; §6.4 step 3 fallback) or the raw stored credential bytes at /// whatever width the server happens to store them (§6.4 step 2 primary) — /// one function serves both r8 and r13. pub fn computeSignature(nonce_hex: []const u8, secret: []const u8) HashHex { std.debug.assert(nonce_hex.len == 40); return sha1HexOf(&.{ nonce_hex, secret }); } test "sync §6.2 [RFC3174] §7.3: SHA1('abc') primitive vector" { const digest = sha1HexOf(&.{"abc"}); try std.testing.expectEqualStrings("a9993e364706816aba3e25717850c26c9cd0d89d", &digest); } test "sync §6.3: shared-secret composes project-code / login / password in order (relational to primitive)" { // §6.3 L1963-1969: shared-secret = SHA1(project-code "/" login "/" // password). Pinned relationally to the RFC3174-anchored sha1HexOf // funnel by spelling the composed byte string with its "/" separators // in order — never a fossil-derived digest. const pc = "c1b2a3d4e5f60718293a4b5c6d7e8f9012345678"; const login = "alice"; const pw = "secret-passphrase"; const secret = deriveSharedSecret(pc, login, pw); const composed = sha1HexOf(&.{ pc, "/", login, "/", pw }); try std.testing.expectEqualStrings(&composed, &secret); // Order matters: swapping login and password composes a different byte // string, hence a different digest. const swapped = sha1HexOf(&.{ pc, "/", pw, "/", login }); try std.testing.expect(!std.mem.eql(u8, &secret, &swapped)); } test "sync §6.4: SIGNATURE is SHA1(NONCE-hex || secret-hex) with no separator (relational to primitive)" { // §6.4 L1978-1985 + L1949-1951: 80-byte input is the two 40-hex // strings concatenated with no separator. Pinned relationally to the // sha1HexOf funnel over the explicitly-spelled 80-byte concatenation. const nonce_hex = "a9993e364706816aba3e25717850c26c9cd0d89d"; const secret_hex = "0123456789abcdef0123456789abcdef01234567"; const sig = computeSignature(nonce_hex, secret_hex); const composed = sha1HexOf(&.{ nonce_hex, secret_hex }); try std.testing.expectEqualStrings(&composed, &sig); // A separator byte between the two fields would change the digest, // proving the concatenation is byte-level with none. const with_sep = sha1HexOf(&.{ nonce_hex, "/", secret_hex }); try std.testing.expect(!std.mem.eql(u8, &sig, &with_sep)); } /// §6.1 — a parsed `login USER NONCE SIGNATURE`. Slices borrow the source /// (a card line or a decoded cookie value); the caller owns that memory. pub const LoginCard = struct { user: []const u8, nonce: []const u8, signature: []const u8 }; /// §6.1 — exactly 40 lowercase hex ASCII bytes, the NONCE/SIGNATURE shape. fn isLowerHex40(s: []const u8) bool { if (s.len != 40) return false; for (s) |c| { switch (c) { '0'...'9', 'a'...'f' => {}, else => return false, } } return true; } /// §6.1 — parse a login card's tokens into a `LoginCard`, or null on any /// malformed shape: wrong token count, an unrecognized first token, an /// empty or Fossil-whitespace-bearing USER, or a NONCE/SIGNATURE that /// isn't exactly 40 lowercase hex. Errors are defined out of existence via /// `?` rather than an error set — every rejection is equally "not a login /// card". `tokens` may come from `tokenizeCard` (body card) or from a /// cookie-decoded line (§6.5), so the operator token is checked here /// rather than assumed already-dispatched. pub fn parseLoginCard(tokens: Tokens) ?LoginCard { std.debug.assert(tokens.len <= max_tokens); if (tokens.len != 4) return null; if (parseOperator(tokens.items[0]) != .login) return null; const user = tokens.items[1]; if (user.len == 0 or containsFossilWhitespace(user)) return null; const nonce = tokens.items[2]; const signature = tokens.items[3]; if (!isLowerHex40(nonce)) return null; if (!isLowerHex40(signature)) return null; return .{ .user = user, .nonce = nonce, .signature = signature }; } /// §6.1 L1922-1926 — write the body-card login form `login USER NONCE /// SIGNATURE` into `out`. `card.user` is the ALREADY-fossilized wire USER /// (fossilization is the card-builder's job via `artifact.encodeEscapes`); /// this writer emits the four tokens verbatim, single-SP separated. The /// no-Fossil-whitespace assertion documents that fossilize (§5.2) has /// escaped every whitespace byte including SP, so the tokens split cleanly /// on the wire. Non-allocating: writes into the caller buffer. Closed /// error set (BufPrintError), no inferred `!`. pub fn writeLogin(card: LoginCard, out: []u8) std.fmt.BufPrintError![]u8 { std.debug.assert(card.user.len > 0 and !containsFossilWhitespace(card.user)); std.debug.assert(card.nonce.len == 40 and card.signature.len == 40); const needed = std.fmt.count("login {s} {s} {s}", .{ card.user, card.nonce, card.signature }); std.debug.assert(out.len >= needed); std.debug.assert(needed <= max_line_len - 1); // §5.2 card-line ceiling return try std.fmt.bufPrint(out, "login {s} {s} {s}", .{ card.user, card.nonce, card.signature }); } /// §6.5 / §B.4 L4630-4631 — the cookie-USER encoding alphabet is pinned by /// the appendix ABNF `pct-user = 1*( unreserved / pct-encoded )` with /// `unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"`: these bytes pass /// through unescaped, every other byte is percent-encoded. That is the /// RFC3986 unreserved set exactly; the §6.5 prose ("URL-unsafe bytes ... /// are percent-encoded", L2097-2098) is made precise by the appendix /// grammar — the normative alphabet lives in §B.4, not in the prose. fn isUrlUnreserved(b: u8) bool { return switch (b) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, else => false, }; } fn hexDigitUpper(n: u4) u8 { const v: u8 = n; return if (v < 10) '0' + v else 'A' + (v - 10); } fn hexNibble(c: u8) ?u4 { return switch (c) { '0'...'9' => @intCast(c - '0'), 'a'...'f' => @intCast(c - 'a' + 10), 'A'...'F' => @intCast(c - 'A' + 10), else => null, }; } /// §6.5 — append `s` to `out` starting at `start`, SP -> '+' and every /// non-unreserved byte -> `%XX`; null on overflow. Bounded by `s.len`. fn urlFormEncodeAppend(s: []const u8, out: []u8, start: usize) ?usize { var o = start; for (s) |b| { if (b == ' ') { if (o >= out.len) return null; out[o] = '+'; o += 1; } else if (isUrlUnreserved(b)) { if (o >= out.len) return null; out[o] = b; o += 1; } else { if (o + 3 > out.len) return null; out[o] = '%'; out[o + 1] = hexDigitUpper(@intCast(b >> 4)); out[o + 2] = hexDigitUpper(@intCast(b & 0x0f)); o += 3; } } return o; } /// §B.4 L4630-4632 — decode one `pct-user` field into `out`: `%XX` /// (HEXDIG-CI, either hex case) -> byte; every other byte passes through /// unchanged. The literal `+` field separator is consumed by /// `parseCookieLogin` before this and never reaches here, so `+` is never /// SP inside a field. Null on a truncated or non-hex escape. Decoding only /// ever shrinks, so `out.len >= field.len` always suffices. fn pctDecodeField(field: []const u8, out: []u8) ?[]u8 { std.debug.assert(out.len >= field.len); var i: usize = 0; var o: usize = 0; while (i < field.len) { if (field[i] == '%') { if (i + 2 >= field.len) return null; const hi = hexNibble(field[i + 1]) orelse return null; const lo = hexNibble(field[i + 2]) orelse return null; out[o] = (@as(u8, hi) << 4) | @as(u8, lo); o += 1; i += 3; } else { out[o] = field[i]; o += 1; i += 1; } } return out[0..o]; } /// §6.5 — encode a login card as the `x-f-l-c` cookie value: `login+USER+ /// NONCE+SIGNATURE`, SP -> '+', URL-unsafe USER bytes percent-encoded. /// NONCE/SIGNATURE are already 40 lowercase hex (unreserved), so only USER /// ever needs escaping. Null on an `out` overflow. pub fn writeCookieLogin(card: LoginCard, out: []u8) ?[]u8 { std.debug.assert(card.user.len > 0); std.debug.assert(card.nonce.len == 40 and card.signature.len == 40); const prefix = "login+"; if (out.len < prefix.len) return null; @memcpy(out[0..prefix.len], prefix); var o = urlFormEncodeAppend(card.user, out, prefix.len) orelse return null; if (o + 1 + card.nonce.len + 1 + card.signature.len > out.len) return null; out[o] = '+'; o += 1; @memcpy(out[o..][0..card.nonce.len], card.nonce); o += card.nonce.len; out[o] = '+'; o += 1; @memcpy(out[o..][0..card.signature.len], card.signature); o += card.signature.len; return out[0..o]; } /// §6.5 / §B.4 L4629 — parse an `x-f-l-c` cookie value into a `LoginCard`; /// null on any malformed value. The grammar is /// `url-login-value = "login+" pct-user "+" nonce "+" signature`: the field /// separator is the literal `+` and nothing else, so the value splits into /// exactly four `+`-delimited segments. Only the `pct-user` segment is /// percent-decoded (into `out`); `nonce` and `signature` are `sha1-hash` /// (40 HEXDIG-LC) and are validated against the untouched wire bytes. A /// `%20` standing in for a `+` separator is therefore not a separator and is /// rejected — the sole grammar leniency is HEXDIG-CI (`pctDecodeField`). /// `out` holds only the decoded USER; the returned card borrows `out` (user) /// and `value` (nonce, signature), both caller-owned for the card's life. pub fn parseCookieLogin(value: []const u8, out: []u8) ?LoginCard { std.debug.assert(out.len >= value.len); if (value.len == 0 or value.len > max_line_len - 1) return null; var fields: [4][]const u8 = undefined; var count: usize = 0; var start: usize = 0; for (value, 0..) |b, idx| { if (b != '+') continue; if (count == 3) return null; // a fifth "+"-delimited segment is malformed fields[count] = value[start..idx]; count += 1; start = idx + 1; } if (count != 3) return null; // exactly three "+" separators -> four fields fields[3] = value[start..]; if (!std.mem.eql(u8, fields[0], "login")) return null; const user = pctDecodeField(fields[1], out) orelse return null; if (user.len == 0 or containsFossilWhitespace(user)) return null; // §6.1 USER shape if (!isLowerHex40(fields[2]) or !isLowerHex40(fields[3])) return null; return .{ .user = user, .nonce = fields[2], .signature = fields[3] }; } test "sync §6.1/§6.5 fuzz: parseLoginCard never crashes on adversarial tokens" { // Untrusted wire tokens: parseLoginCard must never crash, only return // null or a card whose fields satisfy §6.1's own shape invariants. var prng = std.Random.DefaultPrng.init(0x106E_6CDE); const rand = prng.random(); const max_field: usize = 48; var field_buf: [max_tokens][max_field]u8 = undefined; var round: usize = 0; while (round < 500) : (round += 1) { var tokens: Tokens = .{}; const n = rand.uintLessThan(usize, max_tokens + 1); tokens.len = n; var i: usize = 0; while (i < n) : (i += 1) { if (i == 0 and rand.boolean()) { tokens.items[0] = "login"; continue; } const len = rand.uintLessThan(usize, max_field + 1); for (field_buf[i][0..len]) |*b| b.* = rand.int(u8); tokens.items[i] = field_buf[i][0..len]; } const result = parseLoginCard(tokens); if (result) |card| { try std.testing.expect(card.user.len > 0 and !containsFossilWhitespace(card.user)); try std.testing.expect(isLowerHex40(card.nonce)); try std.testing.expect(isLowerHex40(card.signature)); } } } /// §6.4 — constant-time, case-sensitive equality of two 40-hex hashes. /// `b` is wire-derived (a received NONCE/SIGNATURE), so its width is /// checked explicitly rather than assumed. fn eqHash(a: HashHex, b: []const u8) bool { std.debug.assert(b.len == 40); var b_arr: HashHex = undefined; @memcpy(&b_arr, b); return std.crypto.timing_safe.eql(HashHex, a, b_arr); } /// §6.4 outcome. `special_case` is the nobody/anonymous unconditional /// pass; a matching remote-user (HTTP Basic) identity is server-config IO /// and stays out of this pure function. pub const LoginResult = enum { special_case, ok, reject }; /// §6.4 steps 1-4 — verify a login card server-side: nobody/anonymous /// pass unconditionally; otherwise the primary signature check (stored /// bytes as secret) must pass, or — only when it fails and the stored /// credential is not exactly 40 bytes — the stored-credential fallback /// (re-derive via §6.3) must pass; independently, the tail-hash (fresh /// NONCE over `body_tail`) must match the received NONCE. Both checks use /// constant-time comparison. Sans-IO, no allocator. pub fn serverVerifyLogin( card: LoginCard, stored_credential: []const u8, project_code: []const u8, body_tail: []const u8, ) LoginResult { std.debug.assert(card.nonce.len == 40 and card.signature.len == 40); if (std.mem.eql(u8, card.user, "nobody") or std.mem.eql(u8, card.user, "anonymous")) { return .special_case; } const primary_sig = computeSignature(card.nonce, stored_credential); var signature_ok = eqHash(primary_sig, card.signature); if (!signature_ok and stored_credential.len != 40) { const fallback_secret = deriveSharedSecret(project_code, card.user, stored_credential); const fallback_sig = computeSignature(card.nonce, &fallback_secret); signature_ok = eqHash(fallback_sig, card.signature); } const tail_hash = computeNonce(body_tail); const tail_ok = eqHash(tail_hash, card.nonce); return if (signature_ok and tail_ok) .ok else .reject; } test "sync §6.4: eqHash is case-sensitive" { const hex_lower = "d7a235115bf97d126c42140196c4fc6087312353"; var a: HashHex = undefined; @memcpy(&a, hex_lower); try std.testing.expect(eqHash(a, hex_lower)); var flipped: [40]u8 = undefined; @memcpy(&flipped, hex_lower); flipped[0] = 'D'; // flip case of the first hex digit try std.testing.expect(!eqHash(a, &flipped)); } /// §6.3 L1946-1958 — a client's stored login credential: the login name and /// the 40-hex shared secret (`SHA1(project-code "/" login "/" password)`, /// derived once via `deriveSharedSecret`). Sans-IO capability: the plaintext /// password is never held here — only the derived secret — so a request /// builder can sign a nonce (§6.4) without ever touching cleartext. pub const Credential = struct { user: []const u8, secret_hex: [40]u8, }; /// §6.1 L1932-1935, §6.4 step-1 L2030-2032 — the client login identity, /// classifying the nobody/anonymous special names (which carry their /// capabilities without a login card) apart from a real named login. The /// special-name check lives here, once — mirroring `serverVerifyLogin`'s /// `special_case` — rather than leaking a bare login-name string to every /// caller. Sans-IO capability: it carries the login name only, no secret. pub const Identity = union(enum) { anonymous, named: []const u8, /// §6.1 L1932-1935 — map a raw login name to an identity: nobody and /// anonymous collapse to `.anonymous` (no login card is ever emitted /// for them); every other name is a real `.named` login. pub fn fromLoginName(name: []const u8) Identity { if (std.mem.eql(u8, name, "nobody") or std.mem.eql(u8, name, "anonymous")) { return .anonymous; } return .{ .named = name }; } /// §6.1 L1932-1934 — only a named identity authenticates with a login /// card; anonymous/nobody carry configured capabilities unchallenged. fn authenticates(self: Identity) bool { return switch (self) { .anonymous => false, .named => true, }; } }; /// §6.5 — the login channel a client uses for a request: `.none` when no /// login card is emitted at all (anonymous/nobody per §6.1 L1932-1935, or /// clone round 0 per §8.3 L1974-1975), `.body` for the §6.1 body-embedded /// card, `.cookie` for the §6.5 x-f-l-c header against a gated peer. pub const LoginTransport = enum { none, // §6.1 L1933-1935 / §8.3 L1974-1975 — no login card emitted. body, // §6.1 L1923 / §6.5 L2138-2139 — body-embedded login card, no x-f-l-c. cookie, // §6.5 L2094,L2135-2136 — x-f-l-c cookie header, no body login. /// §6.5 L2135-2136 — the x-f-l-c header is emitted only in /// cookie mode; false for both `.body` and `.none`. pub fn emitsCookieHeader(t: LoginTransport) bool { return t == .cookie; } /// §6.1 L1923 / §6.5 L2138-2139 — a body login card is present only /// in body mode; false for both `.cookie` and `.none`. pub fn emitsBodyLogin(t: LoginTransport) bool { return t == .body; } }; /// §6.5 L2086 — peer release integer >= 22700 (Fossil 2.27). pub const cookie_gate_min_version: u32 = 22700; comptime { std.debug.assert(cookie_gate_min_version == encodeVersion(2, 27, 0)); } /// §6.5 L2086 — timestamp on/after 2025-07-27 11:05:00, recorded as /// DATE (YYYYMMDD) / TIME (HHMMSS) per §10.1 L3213-3214. pub const cookie_gate_min_date: u32 = 20250727; pub const cookie_gate_min_time: u32 = 110500; comptime { std.debug.assert(cookie_gate_min_date == 2025 * 10000 + 7 * 100 + 27); std.debug.assert(cookie_gate_min_time == 11 * 10000 + 5 * 100 + 0); } /// §6.5 L2086 (predicate) + L2138-2139 (unobserved fallback) — both gate /// conditions hold for the recorded peer. A peer whose server-version has /// never been observed carries `datetime == null` (§10.1 L3228-3231 seeds /// no timestamp), so the "not yet observed" fallback (§6.5 L2138-2139) is /// subsumed here: no recorded timestamp means not gated. (date, time) /// compares lexicographically because both are fixed-width /// YYYYMMDD/HHMMSS integers that order like the wall clock (§10.1 /// L3213-3214). pub fn peerGatedForCookie(peer: Peer) bool { const dt = peer.datetime orelse return false; if (peer.version < cookie_gate_min_version) return false; if (dt.date < cookie_gate_min_date) return false; if (dt.date == cookie_gate_min_date and dt.time < cookie_gate_min_time) return false; std.debug.assert(peer.version >= cookie_gate_min_version); std.debug.assert(dt.date > cookie_gate_min_date or (dt.date == cookie_gate_min_date and dt.time >= cookie_gate_min_time)); return true; } /// §6.1/§6.5/§8.3 — the single authority for a request's login channel. /// Non-emission comes first: an anonymous/nobody identity (§6.1 /// L1933-1935) and clone round 0 (§8.3 L1974-1975, via the shared /// `sendableAfterCloneRound0`) each yield `.none`. Otherwise the peer gate /// (§6.5 L2135-2139) selects `.cookie` for a gated peer, `.body` /// otherwise. Op-agnostic: identical for pull/push/sync and post-round-0 /// clone. pub fn loginTransport(identity: Identity, peer: Peer, op: Operation, round: u64) LoginTransport { if (!identity.authenticates()) return .none; if (!sendableAfterCloneRound0(op, round)) return .none; const result: LoginTransport = if (peerGatedForCookie(peer)) .cookie else .body; std.debug.assert(result != .none); std.debug.assert(result.emitsCookieHeader() == peerGatedForCookie(peer)); return result; } /// §6.1 L1428-1437, §6.5 L2148-2149 — the one-login-per-request /// invariant across both channels: an x-f-l-c cookie login /// (`cookie_login`) plus `body_logins` body login cards totalling more /// than one is the §11.1 `duplicate_login` protocol error, regardless of /// gating. `body_logins` is a bounded card-scan count the caller /// supplies; this function loops, allocates, and recurses none, so it /// needs no further bound of its own. pub fn duplicateLogin(cookie_login: bool, body_logins: usize) bool { const total = @as(usize, @intFromBool(cookie_login)) + body_logins; std.debug.assert(total >= body_logins); // no wraparound const result = total > 1; if (cookie_login and body_logins >= 1) std.debug.assert(result); if (!cookie_login and body_logins <= 1) std.debug.assert(!result); return result; } // --------------------------------------------------------------------------- // Section 6.6 — Capability Model // --------------------------------------------------------------------------- /// §6.6 L2157-2159 — the capability letters the server assigns: Read (o) /// permits pull, Write (i) permits push, Clone (g) permits clone, Private /// (x) permits private-content transfer. Administrator is named in §6.6 /// with no letter (L2159-2160); its configuration / user-management gate is /// owned by §10.3 serverConfigPushDecision(has_admin), so it is deliberately /// not a member — no §6.6 byte yields it. Distinct from Operation (round /// mode) and Operator (card token); clone/private coexist across the three. pub const Capability = enum { read, write, clone, private }; comptime { // §6.6 assigns exactly four capability letters (o/i/g/x); admin has none. std.debug.assert(@typeInfo(Capability).@"enum".fields.len == 4); } /// §6.6 L2155 — "Users are assigned capability letters by the server." /// Maps an assigned letter to its capability; null for any other byte. pub fn capabilityFromLetter(letter: u8) ?Capability { return switch (letter) { 'o' => .read, 'i' => .write, 'g' => .clone, 'x' => .private, else => null, }; } /// §6.6 L2160-2162 — the established capability set the server checks each /// card against (built §6.4 L2021-2024: baseline set, unioned with a /// successful login card). Bounded by the four-member enum, membership-tested /// by the gate. pub const CapabilitySet = std.EnumSet(Capability); /// §6.6 L2155 — build the established set from the user's assigned letters; /// a byte that names no §6.6 capability is skipped. Bounded by letters.len, /// no allocation — the realization of "assigned capability letters". pub fn capabilitySetFromLetters(letters: []const u8) CapabilitySet { var set: CapabilitySet = .empty; for (letters) |c| { if (capabilityFromLetter(c)) |cap| set.insert(cap); } return set; } /// §6.4 L2021-2024 — a successful login card is purely additive: the /// established set is the baseline unioned with the login grant, and a /// grant can only add bits, never remove one. The baseline itself is /// host/session-derived (§6.4 L2008-2017) and enters as an injected /// CapabilitySet — no IO here. Non-failing: no way to remove a bit, so /// no error union. pub fn establishedCapabilities(baseline: CapabilitySet, login_grant: CapabilitySet) CapabilitySet { const result = baseline.unionWith(login_grant); // §6.4 L2021-2024 additive-only invariant (negative space: no baseline bit is cleared). std.debug.assert(result.supersetOf(baseline)); return result; } /// §6.6 L2157-2159 — the capability a transfer operation requires: pull /// needs Read (o), push needs Write (i), clone needs Clone (g). `sync` is a /// combined pull+push round, not a single card, so §6.6 assigns it no /// single letter and this returns null; its pull and push cards are each /// gated as processed. Private-content transfer (x) and configuration /// (admin) are gated by their own §7.5/§10 deciders, never keyed here (that /// would duplicate those gates). Exhaustive over Operation, no `else`. pub fn requiredCapability(op: Operation) ?Capability { return switch (op) { .pull => .read, .push => .write, .clone => .clone, .sync => null, }; } /// §6.6 L2160-2162 — the per-card capability gate's disposition: process /// the operation, or reject (r8: the server emits an error card). §6.6 /// fixes no error-card text for a lacked transfer capability, so `.reject` /// IS the "error-card form" here — no fabricated golden bytes. pub const CapabilityDecision = enum { process, reject }; /// §6.6 L2160-2162 — process the card when the user's established /// capability set holds the capability the operation requires, else /// reject. Precondition op != .sync: the combined round is decomposed /// into per-card pull/push checks upstream and never handed here. pub fn serverCapabilityDecision(held: CapabilitySet, op: Operation) CapabilityDecision { std.debug.assert(op != .sync); const required = requiredCapability(op).?; const result: CapabilityDecision = if (held.contains(required)) .process else .reject; std.debug.assert((result == .process) == held.contains(required)); return result; } // --------------------------------------------------------------------------- // Section 7.1 / 7.2 / 7.4 — Content Transfer: file / cfile Cards // --------------------------------------------------------------------------- /// §7.1-r1/r5 — a `file` card: HASH, an optional delta basis (four-argument /// form only), and SIZE, the uncompressed payload byte count. pub const FileCard = struct { hash: []const u8, deltasrc: ?[]const u8, size: u32, }; /// §7.2-r1/r2 — a `cfile` card: HASH, an optional delta basis, USIZE (the /// sender's reconstructed uncompressed size — syntax only, §7.2-r5), and /// CSIZE, the wire payload byte count that actually frames the payload. pub const CfileCard = struct { hash: []const u8, deltasrc: ?[]const u8, uncompressed_size: u32, compressed_size: u32, }; /// §7 — the sole `file`/`cfile` parse failure. Unlike §10.3's `config`, /// SIZE/USIZE/CSIZE have no signed literal to reject as a distinct case; /// a negative token is simply unparseable as `u32` and folds into this. pub const CardParseError = error{Malformed}; /// §7.1-r1/r5 — parse `file HASH SIZE` or `file HASH DELTASRC SIZE`. /// Arity alone disambiguates the two forms; DELTASRC is never inspected /// beyond being present, so this layer takes no hash-policy dependency. pub fn parseFileCard(tokens: Tokens) CardParseError!FileCard { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .file); std.debug.assert(tokens.len <= max_tokens); const result: FileCard = switch (tokens.len) { 3 => .{ .hash = tokens.items[1], .deltasrc = null, .size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed, }, 4 => .{ .hash = tokens.items[1], .deltasrc = tokens.items[2], .size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed, }, else => return CardParseError.Malformed, }; std.debug.assert((result.deltasrc == null) == (tokens.len == 3)); return result; } /// §7.2-r1/r2 — parse `cfile HASH USIZE CSIZE` or /// `cfile HASH DELTASRC USIZE CSIZE`. Same arity-disambiguation shape as /// `parseFileCard`. pub fn parseCfileCard(tokens: Tokens) CardParseError!CfileCard { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .cfile); std.debug.assert(tokens.len <= max_tokens); const result: CfileCard = switch (tokens.len) { 4 => .{ .hash = tokens.items[1], .deltasrc = null, .uncompressed_size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed, .compressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed, }, 5 => .{ .hash = tokens.items[1], .deltasrc = tokens.items[2], .uncompressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed, .compressed_size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed, }, else => return CardParseError.Malformed, }; std.debug.assert((result.deltasrc == null) == (tokens.len == 4)); return result; } /// §7.4/§7.7 — the shared delta-reception core, used by BOTH the client receive /// path (`Client.receiveDelta`) and the stateless server push-ingest path /// (`scanCard`): reconstruct a delta-form target's content against its already- /// held basis and, when `verify_integrity` is set, enforce §7.7 pre-storage /// integrity. Kept as one helper so the reconstruct-then-admit rule lives in a /// single place rather than in two divergent copies. const ReconstructError = error{ OutOfMemory, MalformedDelta, IntegrityMismatch }; /// §7.4/§7.7 — apply `delta_bytes` (a [FossilDelta], already §4.1 container- /// decoded for a cfile) against the held basis content `src_bytes` to /// reconstruct `hash`'s bytes. On success returns caller-owned reconstructed /// bytes that, when `verify_integrity` is set, are proven to hash to `hash` /// (§7.7: integrity hashes the RECONSTRUCTED bytes, never the wire delta), so a /// wrong-hash delta never lands bytes under a name they do not match in the /// content-addressed store. A clone-context file bypasses the target check /// (§7.7), which the caller signals by passing `verify_integrity = false`. fn reconstructDeltaTarget( gpa: std.mem.Allocator, hash: []const u8, src_bytes: []const u8, delta_bytes: []const u8, verify_integrity: bool, ) ReconstructError![]u8 { const reconstructed = delta.apply(gpa, src_bytes, delta_bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.MalformedDelta, error.ChecksumMismatch => return error.MalformedDelta, }; errdefer gpa.free(reconstructed); if (verify_integrity and !artifact.verify(hash, reconstructed)) return error.IntegrityMismatch; return reconstructed; } /// §7.1/§7.4/§7.7 — reconstruct a push-ingested delta-form `file` card against a /// basis the stateless server already holds, verifying §7.7 pre-storage /// integrity (a push is never a clone, so the target check always applies). On /// success returns caller-owned reconstructed bytes; the caller stores them /// exactly as a full-content pushed artifact. /// /// The server is stateless (§3.8): unlike `Client.receiveDelta` it keeps no /// cross-request `PendingDeltaQueue`, so it cannot retain a dangling delta until /// a future request delivers the basis. A basis absent from the store is /// therefore `BasisAbsent` — the caller rejects the card rather than silently /// dropping it, and never lands an unverifiable delta under `hash`. This branch /// is unreachable by a conformant peer: Fossil deltas a pushed artifact solely /// against content the server advertised holding (§3.2 igot), so on a real push /// the basis is always present and reconstruction runs. const PushedDeltaError = error{ OutOfMemory, BasisAbsent, MalformedDelta, IntegrityMismatch }; fn reconstructPushedDelta( gpa: std.mem.Allocator, store: repostate.ArtifactStore, hash: []const u8, deltasrc: []const u8, delta_bytes: []const u8, ) PushedDeltaError![]u8 { const src_rid = store.lookup(deltasrc) orelse return error.BasisAbsent; const src_bytes = store.content(src_rid) orelse return error.BasisAbsent; return reconstructDeltaTarget(gpa, hash, src_bytes, delta_bytes, true); } /// §7.1-r2/r3/r4, §7.2-r2/r3/r4 — the payload bytes plus the offset just /// past them. pub const ContentPayload = struct { bytes: []const u8, next: usize }; /// §7.1-r3/r4, §7.2-r3/r4 — the sole content-frame failure: the declared /// span runs past the buffer. There is no `MissingNewline` arm (unlike /// `ConfigFrameError`): §7.1-r4/§7.2-r4 mandate no trailing separator, so /// that failure mode is defined out of existence here. pub const ContentFrameError = error{Truncated}; /// §7.1-r2/r3/r4, §7.2-r2/r3/r4 — slice out exactly `size` payload bytes /// starting at `start`; the byte at `start + size` (if any) is the first /// byte of the next card line, never consumed. Overflow-safe: the add is /// checked so an adversarial `size` cannot wrap `usize` into passing the /// bound check. Shared verbatim by `file` and `cfile`; callers pass /// CSIZE — never USIZE — for cfile, which structurally enforces §7.2-r3 /// since `CfileCard.uncompressed_size` never reaches this parameter in /// the framing call sites above. pub fn consumeContentPayload(body: []const u8, start: usize, size: u32) ContentFrameError!ContentPayload { std.debug.assert(start <= body.len); const end = std.math.add(usize, start, size) catch return ContentFrameError.Truncated; if (end > body.len) return ContentFrameError.Truncated; const result: ContentPayload = .{ .bytes = body[start..end], .next = end }; std.debug.assert(result.next == start + @as(usize, size)); std.debug.assert(result.bytes.len == size); return result; } /// §5.1 L877-879 — the resume offset past a framed cfile payload, consuming the /// optional trailing-LF padding. A cfile sender "appends one further LF only /// when the counted compressed payload does not itself end in LF; reference /// receivers consume that padding as an empty line." The condition keys on the /// payload's LAST byte alone — the compressed bytes legally embed LF elsewhere /// (§5.1 L875-876) — so a payload that self-terminates in LF carries no padding, /// and a real following empty line is left for the card loop to reject (negative /// space). The skip is bounded: the padding LF is consumed only when it is /// actually on the wire, so a no-padding sender or a truncated final cfile never /// over-advances. `writeCfile` emits under the identical predicate (round-trip). pub fn cfilePaddingEnd(body: []const u8, payload: ContentPayload) usize { std.debug.assert(payload.next <= body.len); const ends_in_lf = payload.bytes.len != 0 and payload.bytes[payload.bytes.len - 1] == '\n'; if (!ends_in_lf and payload.next < body.len and body[payload.next] == '\n') { return payload.next + 1; // §5.1 — consume the one padding LF as an empty line } return payload.next; // §5.1 — payload self-terminated, or no padding present } /// §7.4-r1 — one delta held pending its basis: HASH's delta against a DELTASRC /// this receiver does not yet hold. The retained `payload` is the exact bytes /// framed off the card; nothing further is decoded until the basis arrives. pub const PendingDelta = struct { hash: []const u8, deltasrc: []const u8, payload: Payload, /// §7.1/§7.2 — the retained delta form, discriminated by whether a §4.1 /// container was on the wire. `.container` is a cfile card's stored /// compressed container of the [FossilDelta], kept VERBATIM so completion /// persists the compact §7.2 delta frame and a clone-of-clone re-serves it /// byte-for-byte. `.raw` is a `file` card's bare [FossilDelta] (§7.1), which /// has no container to preserve and completes to full form. Keeping the /// compressed container — not the larger decoded delta — also shrinks the /// live queue during a clone (a real reply fans out thousands of these). pub const Payload = union(enum) { container: []const u8, raw: []const u8, /// The retained delta bytes, whichever variant. Both are `[]const u8`, /// so freeing and length checks share one accessor. fn bytes(self: Payload) []const u8 { return switch (self) { .container => |b| b, .raw => |b| b, }; } }; }; /// §7.4-r1/r3 — deltas retained against an absent basis, replayed once the /// basis is stored. Heap-backed and growable: a real clone reply legitimately /// carries thousands of dangling deltas (§7.4 — fossil sends a cfile delta /// before its basis arrives, and the receiver MUST retain it), so a fixed /// slot count would abort a conformant exchange. Growth is not unbounded: the /// §4 body cap already ceils how many cards a reply can carry, and `max_pending` /// derives that ceiling explicitly (below), so a runaway peer still cannot make /// this list allocate without bound. pub const PendingDeltaQueue = struct { /// The smallest number of reply-body bytes a delta-bearing card can occupy: /// a `file HASH DELTASRC SIZE\n` header over two 40-hex (SHA-1) names, a /// one-digit SIZE, single-space separators and the terminating LF, plus at /// least one payload byte. Any real card is larger (cfile has a longer /// keyword, SHA-3 names are 64 hex, payloads exceed one byte), so this is a /// safe lower bound — it can only over-count how many cards fit, never /// under-count, so the ceiling it yields never rejects a conformant reply. pub const min_delta_card_bytes: usize = "file ".len + 40 + " ".len + 40 + " ".len + "1".len + "\n".len + 1; /// §7.4 + §4 L1146-1149 — the retention ceiling. Not an arbitrary constant: /// a Content-Length-less reply body is capped at `reply_body_cap` bytes /// (§4-r8), and each retained delta consumed at least `min_delta_card_bytes` /// of that body, so no conformant reply can present more than /// `reply_body_cap / min_delta_card_bytes` dangling deltas. A peer that /// exceeds it has already exceeded the body cap; the queue rejects it rather /// than allocate past a bound the protocol itself guarantees. pub const max_pending: usize = reply_body_cap / min_delta_card_bytes; pub const RetainError = error{QueueFull} || std.mem.Allocator.Error; items: std.ArrayListUnmanaged(PendingDelta) = .empty, /// The retention ceiling enforced by `retain`; defaults to the §7.4/§4-derived /// `max_pending`. A caller may lower it (tests exercise the QueueFull edge /// with a small ceiling) but never needs to raise it — the body cap already /// bounds a conformant peer below `max_pending`. cap: usize = max_pending, /// Number of deltas currently retained. Hides the backing representation so /// callers never reach into `items` directly. pub fn itemCount(self: *const PendingDeltaQueue) usize { return self.items.items.len; } /// Free every retained entry's owned bytes and the list backing. A queue /// still holding deltas at session end (their basis never arrived — §7.7 /// permits the session to end with an unresolved phantom) is drained here so /// nothing leaks. pub fn deinit(self: *PendingDeltaQueue, gpa: std.mem.Allocator) void { for (self.items.items) |entry| freeEntry(gpa, entry); self.items.deinit(gpa); } fn freeEntry(gpa: std.mem.Allocator, entry: PendingDelta) void { gpa.free(entry.hash); gpa.free(entry.deltasrc); gpa.free(entry.payload.bytes()); } /// §7.4-r1 — retain a private copy of `hash`/`deltasrc` and the delta /// `payload`, deferring reconstruction to `completeFor`. The inputs slice /// into the per-round reply body (freed at the round boundary) while the /// basis may not arrive until a later round, so the queue OWNS its copies /// and frees them on completion or `deinit`. `payload` is borrowed here and /// duplicated; pass `.container` for a cfile (the §4.1 container retained /// verbatim) or `.raw` for a `file` card (the bare [FossilDelta]). Does not /// touch the store: creating the DELTASRC phantom (§7.4-r2) is the caller's /// job via `store.intern`. pub fn retain(self: *PendingDeltaQueue, gpa: std.mem.Allocator, hash: []const u8, deltasrc: []const u8, payload: PendingDelta.Payload) RetainError!void { std.debug.assert(self.items.items.len <= self.cap); if (self.items.items.len >= self.cap) return RetainError.QueueFull; const hash_owned = try gpa.dupe(u8, hash); errdefer gpa.free(hash_owned); const src_owned = try gpa.dupe(u8, deltasrc); errdefer gpa.free(src_owned); const payload_owned: PendingDelta.Payload = switch (payload) { .container => |b| .{ .container = try gpa.dupe(u8, b) }, .raw => |b| .{ .raw = try gpa.dupe(u8, b) }, }; errdefer gpa.free(payload_owned.bytes()); try self.items.append(gpa, .{ .hash = hash_owned, .deltasrc = src_owned, .payload = payload_owned }); std.debug.assert(self.items.items.len <= self.cap); } /// §7.4-r3 — `deltasrc` has just been stored; complete every deferred delta /// that named it as basis and drop the entry. A clone `.container` entry lands /// wire-backed (verbatim container, lazy reconstruction); a pull `.raw` entry /// reconstructs against the now-held basis and stores the full plaintext. /// Swap-remove: the queue carries no arrival order to preserve. pub fn completeFor( self: *PendingDeltaQueue, gpa: std.mem.Allocator, store: repostate.ArtifactStore, deltasrc: []const u8, ) (delta.Error || std.mem.Allocator.Error)!void { // §7.4-r3 cascades: reconstructing a target STORES it, which is itself a // "source artifact subsequently stored" event for any pending delta that // named that target as its basis (a delta chain C<-A<-B). Drive the chain // to fixpoint via a worklist of newly-stored sources. Bounded: each queue // entry completes at most once (swap-removed and freed), so at most // `initial_len` target hashes are ever enqueued, plus the initial // deltasrc. Reserve that exact capacity up front so the appends below // never reallocate and the fixpoint loop terminates, asserted at the end. const initial_len = self.items.items.len; var work: std.ArrayListUnmanaged([]const u8) = .empty; defer work.deinit(gpa); try work.ensureTotalCapacityPrecise(gpa, initial_len + 1); work.appendAssumeCapacity(deltasrc); var done: usize = 0; while (done < work.items.len) { const src = work.items[done]; done += 1; var i: usize = 0; while (i < self.items.items.len) { if (!std.mem.eql(u8, self.items.items[i].deltasrc, src)) { i += 1; continue; } // `src` is stored before it enters the worklist (the caller stored // the initial deltasrc, and every cascaded src was stored below), so // `completeEntry` resolves it when it needs the basis. A clone's // `.container` entries need NO basis plaintext at all — they land // wire-backed — so the basis is never materialised here (peak stays // O(wire)); only a pull's `.raw` entry reads it, lazily. const target_rid = try self.completeEntry(gpa, store, self.items.items[i]); // The target is now stored: enqueue it as a basis for any other // pending delta (§7.4-r3 cascade). Capacity was reserved above. std.debug.assert(work.items.len < initial_len + 1); work.appendAssumeCapacity(store.hashOf(target_rid)); freeEntry(gpa, self.items.items[i]); _ = self.items.swapRemove(i); // Do not advance i: index i now holds the swapped-in element. } } std.debug.assert(work.items.len <= initial_len + 1); // §7.4 fixpoint terminates } /// §7.2/§7.4 — reconstruct one now-completable `entry` against its held /// basis `source_bytes`, store the reconstructed plaintext, and (for a cfile /// `.container` payload) retain the received §4.1 delta container VERBATIM /// keyed to the new rid, so the persist commit hook writes a compact `.delta` /// frame and a clone-of-clone re-serves the exact bytes (§7.2). A `.raw` /// payload (file card) has no container to preserve and lands as plaintext, /// which the persist path compresses to full form — the pre-slice-A /// behaviour, unchanged. §7.7 verification stays deferred to the persist /// commit hook (`persistStore`), which recomputes the hash before any durable /// write; the in-memory retention here is advisory. Returns the target rid. fn completeEntry( self: *PendingDeltaQueue, gpa: std.mem.Allocator, store: repostate.ArtifactStore, entry: PendingDelta, ) (delta.Error || std.mem.Allocator.Error)!repostate.Rid { _ = self; switch (entry.payload) { .raw => |raw| { // §7.1 file-card delta (pull): no container to preserve, so // reconstruct against the now-held basis and store the full // plaintext. The basis was interned before this entry entered the // worklist, so its lookup is present (§7.4 precondition). Its // content, however, may NOT be materialisable: the W4 full-form // path (§7.2/§7.7) now retains a stored cfile container VERBATIM // and defers §7.7 inflate/verify to read, so a corrupt full // container is interned+retained with a null content(). When such a // basis backs this dependent delta, the plaintext cannot be // reconstructed — surface it as MalformedDelta so completeFor's call // site aborts the round (§5.2), preserving the pre-W4 "abort on // corrupt basis" outcome without the eager receive-time inflate. const src_rid = store.lookup(entry.deltasrc) orelse unreachable; const source_bytes = store.content(src_rid) orelse return error.MalformedDelta; const reconstructed = try delta.apply(gpa, source_bytes, raw); defer gpa.free(reconstructed); return store.put(entry.hash, reconstructed); }, .container => |container| { // §7.2/§7.4 clone cfile delta — store the verbatim §4.1 container // WIRE-BACKED (basis name + reconstructed USIZE), NOT the inflated // target: content() reconstructs lazily and the persist commit hook // verifies §7.7, so a clone never memoises the completed target // (peak stays O(wire)). The USIZE is the delta §3.2 header size, read // WITHOUT applying the delta. A corrupt retained delta — decode or a // malformed header — maps to MalformedDelta, aborting the round // exactly as the cfile handler would. Bounded by the reply body cap. const raw = codec.decode(gpa, container, reply_body_cap) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.TruncatedHeader, error.InvalidStream, error.OutputTooLarge => return error.MalformedDelta, }; defer gpa.free(raw); const target_size = delta.targetSize(raw) catch return error.MalformedDelta; const rid = try store.intern(entry.hash); store.retain(rid, .{ .delta = .{ .src = entry.deltasrc, .container = container, .target_size = target_size } }) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, }; return rid; }, } } /// §3.3 L664-680 dangling-delta path — OPTION B content-received /// derivation. A HASH whose delta payload has been retained (its basis /// not yet local) is content-received: §3.3 deletes its phantom on delta /// receipt, so gimme MUST NOT re-request it. The store stays binary /// (present / phantom), so that state is derived here — a live queue /// entry naming HASH as target — never a store mutation. The /// disjunction's other half (content already stored) is the store's own /// `content(rid) != null`, which the gimme driver checks as it /// enumerates phantoms; this predicate answers only the retained-delta /// half. A pure membership test: no error set, errors defined away. pub fn hasTarget(self: *const PendingDeltaQueue, hash: []const u8) bool { std.debug.assert(self.items.items.len <= self.cap); std.debug.assert(hash.len == 40 or hash.len == 64); for (self.items.items) |entry| if (std.mem.eql(u8, entry.hash, hash)) return true; return false; } }; // §A.1 L2998-3035 (Round 2 request of the two-round push/pull worked // example) — the vector holds the first file card's 272-byte manifest // payload, the second file card's 26-byte payload, and enough of the // following gimme card to prove the "no separator" boundary lands // exactly where the RFC's own byte counts say it does. // §7.4-r2 — the receiver "MUST create a phantom record for the named // DELTASRC". A phantom is `ArtifactStore`'s own contract (content(rid) == // null), so this is asserted directly against the store, mirroring the // §3.3 r24 store-contract test above: no sync-layer isPhantom wrapper. // --------------------------------------------------------------------------- // Section 7.5 — Private Content // --------------------------------------------------------------------------- /// §7.5 L1750-1752 (r4/r5) — the sole holds-Private gate on receipt: an /// artifact is stored only when the authenticated user holds the /// Private capability; otherwise it is discarded without error (r5) — /// the plain enum, not an error union, is what defines "without error" /// out of existence. pub const PrivateReceipt = enum { store, discard }; pub fn privateReceipt(user_has_private: bool) PrivateReceipt { const result: PrivateReceipt = if (user_has_private) .store else .discard; std.debug.assert((result == .store) == user_has_private); return result; } /// §7.5 L1758-1764 (r7) — send-side delta-basis gate. Precondition: the /// artifact being sent is itself public — the boundary only bites when a /// *public* artifact's on-disk delta basis is private. Sending such a /// delta to a peer lacking private-content authorization would leak the /// basis artifact's existence/identity, so the sender is forced to full, /// non-delta content instead. pub const DeltaBasisSend = enum { delta_ok, force_full }; pub fn deltaBasisSend(basis_private: bool, peer_has_private: bool) DeltaBasisSend { const result: DeltaBasisSend = if (basis_private and !peer_has_private) .force_full else .delta_ok; std.debug.assert((result == .force_full) == (basis_private and !peer_has_private)); return result; } /// §7.5 L2476-2483 (r9/r10/r11/r12) — receive-side disposition of a /// private `igot HASH 1` from a peer that lacks private-content /// authorization: never an error (r9), never creates a phantom (r10), /// marks an already-held phantom private (r11), and always records that /// the peer holds HASH (r12). `already_phantom` is the caller's lookup /// of its own phantom table; the caller applies `mark_phantom_private` /// back to that table — same returned-decision contract as /// `igotDisposition`. No repostate code lands here. pub const PrivateIgotReceipt = struct { is_error: bool, create_phantom: bool, mark_phantom_private: bool, record_peer_has: bool, }; pub fn receivePrivateIgot(already_phantom: bool) PrivateIgotReceipt { const result: PrivateIgotReceipt = .{ .is_error = false, .create_phantom = false, .mark_phantom_private = already_phantom, .record_peer_has = true, }; std.debug.assert(!result.is_error and !result.create_phantom and result.record_peer_has and result.mark_phantom_private == already_phantom); return result; } /// §9.2 L2191-2192 — a parsed `igot HASH` (public) or `igot HASH 1` /// (private) card. `1` is the only flag value this grammar defines. pub const IgotCard = struct { hash: []const u8, private: bool }; /// §9.2 L2191-2192 — parse `igot HASH` or `igot HASH 1`. Arity alone /// disambiguates the two forms, same shape as `parseFileCard`/ /// `parseUvigotCard`; a third token other than the literal "1" is /// malformed, and any arity outside {2,3} is malformed. pub fn parseIgotCard(tokens: Tokens) CardParseError!IgotCard { std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens); std.debug.assert(parseOperator(tokens.items[0]) == .igot); const result: IgotCard = switch (tokens.len) { 2 => .{ .hash = tokens.items[1], .private = false }, 3 => blk: { if (!std.mem.eql(u8, tokens.items[2], "1")) return CardParseError.Malformed; break :blk .{ .hash = tokens.items[1], .private = true }; }, else => return CardParseError.Malformed, }; std.debug.assert(!result.private or tokens.len == 3); return result; } /// §3.3 r27 — a parsed `gimme HASH` card. pub const GimmeCard = struct { hash: []const u8 }; /// §3.3 r27 L633-637 — parse `gimme HASH`. Arity 2 exactly; HASH is a 40-hex /// SHA1 or 64-hex SHA3 token (the §3.1 hash-token lengths `recordPeerLacks` /// records into). Any other arity or hash length is malformed and rejected /// here — so the 40/64 length is never asserted downstream on wire input. pub fn parseGimmeCard(tokens: Tokens) CardParseError!GimmeCard { std.debug.assert(tokens.len >= 1); std.debug.assert(parseOperator(tokens.items[0]) == .gimme); if (tokens.len != 2) return CardParseError.Malformed; const hash = tokens.items[1]; if (hash.len != 40 and hash.len != 64) return CardParseError.Malformed; std.debug.assert(hash.len == 40 or hash.len == 64); return .{ .hash = hash }; } // --------------------------------------------------------------------------- // Section 7.7 — Hash-Policy Admission // --------------------------------------------------------------------------- /// §7.7 L1818-1824 — hash-policy admission: a gate on the inbound artifact's /// HASH NAME (length alone → algorithm, artifact.nameAlgo), governed by the /// receiver's configured policy. Distinct from content-integrity (r1): it /// never recomputes or inspects content. Under shun-sha1 a 40-char (SHA1) /// name is rejected before storage (r2); every other policy admits. One /// function serves all three call sites — file, cfile, and cfile on the /// clone 3 SEQNO path (r3/r3b) — since the decision depends only on /// (policy, name), never on the card type or path. pub const Admission = enum { admit, reject }; pub fn admitInbound(policy: artifact.HashPolicy, hash: []const u8) Admission { std.debug.assert(artifact.nameAlgo(hash.len) != null); // parse layer validated the name const result: Admission = if (policy == .shun_sha1 and hash.len == 40) .reject else .admit; std.debug.assert((result == .reject) == (policy == .shun_sha1 and hash.len == 40)); return result; } /// §7.7 L1826-1832 — the content-integrity check-point schedule for one /// inbound content card. `pre_store` is the transfer-layer recompute-over- /// reconstructed-bytes check (artifact.verify on the delta-applied content, /// not the wire payload — r5); it fires only on non-clone file cards (r6). /// cfile cards never pre-store-verify in the transfer layer (r7). `at_commit` /// is the universal: commit-time verification applies on ALL paths (r6/r7 /// second clauses) — always true, pinned by assertion and by every test combo. pub const IntegritySchedule = struct { pre_store: bool, at_commit: bool }; pub fn integritySchedule(op: Operator, clone_path: bool) IntegritySchedule { std.debug.assert(op == .file or op == .cfile); // §7.7 governs file/cfile; uvfile is §7.3-r13 const result: IntegritySchedule = .{ .pre_store = (op == .file and !clone_path), .at_commit = true }; std.debug.assert(result.at_commit); // commit-time on all paths, no branch may drop it std.debug.assert(result.pre_store == (op == .file and !clone_path)); return result; } // --------------------------------------------------------------------------- // Section 7.3 — uvfile Card // --------------------------------------------------------------------------- /// §7.3 — FLAGS bit 0x0001: deletion marker. Sender-side metadata only; the /// receiver decides delete-vs-update from HASH (see isDeletion), but payload /// framing gates on this bit (see uvfilePayloadFollows). pub const uvfile_flag_deletion: u32 = 0x0001; /// §7.3-r7 — FLAGS bit 0x0004: content is omitted from this transfer. pub const uvfile_flag_omitted: u32 = 0x0004; /// §7.3-r1/r2 — a `uvfile` card: unversioned-file sync state, carried /// entirely outside the artifact model, its hash policy, and its /// tag-state rules ([FossilArtifact] plays no part in this parse). pub const UvfileCard = struct { name: []const u8, // §7.3-r3 — fossilize-escaped; NOT decoded here mtime: i64, // §7.3-r4 — decimal seconds since the Unix epoch hash: []const u8, // §7.3-r5/r16 — artifact-hash, or "-" for a deletion size: u32, // §7.3-r6 — payload byte count following the card line flags: u32, // §7.3-r7 — defined low bits of the decimal wire bit field /// §7.3 L1758-1759 — the receiver decides deletion from the HASH /// token, never the 0x0001 bit: a deletion is signaled by HASH /// equal to "-" (with SIZE 0); a sender emitting a deletion also /// sets 0x0001, but that bit is sender-side metadata only. pub fn isDeletion(self: UvfileCard) bool { return std.mem.eql(u8, self.hash, "-"); } pub fn contentOmitted(self: UvfileCard) bool { return self.flags & uvfile_flag_omitted != 0; } }; /// §7.3-r15 — the payload-free announcement form of a uvfile's state. pub const UvigotCard = struct { name: []const u8, mtime: i64, hash: []const u8, size: u32 }; /// §7.3-r17 — a request for one named unversioned file. pub const UvgimmeCard = struct { name: []const u8 }; /// §7.3/App. B — the numeric UV fields use `decimal = 1*DIGIT`; signs, /// separators, and an empty token are not wire decimals. Range checking remains /// field-specific, so SIZE stays bounded while FLAGS uses its own parser below. fn parseUvDecimal(comptime T: type, token: []const u8) CardParseError!T { for (token) |byte| { if (byte < '0' or byte > '9') return CardParseError.Malformed; } if (token.len == 0) return CardParseError.Malformed; return std.fmt.parseInt(T, token, 10) catch return CardParseError.Malformed; } /// §7.3-r7 — FLAGS is an unbounded decimal bit field. Only defined low bits /// survive validation; compute modulo 8 while scanning so reserved high bits /// never impose a fixed-width range limit. fn parseUvFlags(token: []const u8) CardParseError!u32 { var modulo_eight: u32 = 0; for (token) |byte| { if (byte < '0' or byte > '9') return CardParseError.Malformed; modulo_eight = (modulo_eight * 10 + @as(u32, byte - '0')) & 0x7; } if (token.len == 0) return CardParseError.Malformed; return modulo_eight & 0x5; } /// §7.3-r2 — parse the six captured `uvfile` slots: `NAME MTIME HASH SIZE /// FLAGS`. The fixed parser window intentionally ignores any later visible /// tokens (§5.2 L1818-1830); every other UV card retains strict arity. pub fn parseUvfileCard(tokens: Tokens) CardParseError!UvfileCard { std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens); std.debug.assert(parseOperator(tokens.items[0]) == .uvfile); if (tokens.len != 6) return CardParseError.Malformed; const mtime = try parseUvDecimal(i64, tokens.items[2]); const size = try parseUvDecimal(u32, tokens.items[4]); const flags = try parseUvFlags(tokens.items[5]); return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size, .flags = flags }; } /// §7.3-r15 — parse `uvigot NAME MTIME HASH SIZE`. Arity 5 is below /// `max_tokens` (6); `tokenizeCard` only ever sets `surplus` when /// `len == max_tokens`, so a strict `len != 5` already excludes every /// surplus line — no separate guard needed. pub fn parseUvigotCard(tokens: Tokens) CardParseError!UvigotCard { std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens); std.debug.assert(parseOperator(tokens.items[0]) == .uvigot); if (tokens.len != 5) return CardParseError.Malformed; const mtime = try parseUvDecimal(i64, tokens.items[2]); const size = try parseUvDecimal(u32, tokens.items[4]); return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size }; } /// §7.3-r5/r16 — an unversioned hash is either the deletion literal `-` or a /// canonical artifact hash. It deliberately has no repository hash-policy /// dependency. fn validUvHash(hash: []const u8) bool { return std.mem.eql(u8, hash, "-") or artifact.validateName(hash); } /// §7.3-r17 — parse `uvgimme NAME`. Same below-max-tokens arity /// argument as `parseUvigotCard`: `len != 2` alone excludes every /// surplus line. pub fn parseUvgimmeCard(tokens: Tokens) CardParseError!UvgimmeCard { std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens); std.debug.assert(parseOperator(tokens.items[0]) == .uvgimme); if (tokens.len != 2) return CardParseError.Malformed; return .{ .name = tokens.items[1] }; } /// §7.3-r9/r10/r12 — true iff a payload follows this card's LF: SIZE is /// positive and neither the deletion (0x0001) nor the omitted-content (0x0004) /// FLAGS bit is set. Payload framing keys on the FLAGS bits (`flags & 0x0005`), /// not on the HASH-based deletion test that governs delete-vs-update; this /// matches the reference receiver per the §7.3 uvfile-flags erratum. pub fn uvfilePayloadFollows(card: UvfileCard) bool { return card.size > 0 and (card.flags & (uvfile_flag_deletion | uvfile_flag_omitted)) == 0; } /// §7.3-r10/r11/r12 — frame the payload declared by `card`, or the /// empty span immediately after `start` when none follows (r12). The /// payload-present path delegates to `consumeContentPayload`'s /// overflow-checked bound. pub fn consumeUvfilePayload(body: []const u8, start: usize, card: UvfileCard) ContentFrameError!ContentPayload { std.debug.assert(start <= body.len); if (!uvfilePayloadFollows(card)) { const result: ContentPayload = .{ .bytes = body[start..start], .next = start }; std.debug.assert(result.bytes.len == 0 and result.next == start); return result; } return consumeContentPayload(body, start, card.size); } /// §7.3-r13 — verify HASH against `content` before the caller stores /// it. A garbage-length `hash` (neither 40 nor 64 hex) simply fails to /// verify rather than panicking on an unrecognized algorithm. pub fn verifyUvContent(hash: []const u8, content: []const u8) bool { const algo = artifact.nameAlgo(hash.len) orelse return false; var buf: [64]u8 = undefined; return std.mem.eql(u8, artifact.artifactName(algo, content, &buf), hash); } /// §7.3-r18 — the outcome of landing one incoming uvfile: accepted, a /// write-permission rejection, or a no-op (identical, local wins, or /// dry-run). pub const UvLanding = enum { land, not_authorized, no_op }; /// §7.3-r18, §9.1.1-uv2 — recheck the same status function (`uvDecide`) /// and the write-permission gate before landing an incoming uvfile. /// `pull_only` is always passed `false`: it drives only `send_table` /// (never read on the landing path), and `false` is its /// least-restrictive setting, so it never masks the `.local` outcome /// this function reads. pub fn uvLandDecision(local: ?UvRow, card: UvfileCard, mode: UvMode, may_write: bool) UvLanding { const result: UvLanding = if (!may_write) .not_authorized // §7.3-r18 else switch (uvDecide(local, card.mtime, card.hash, mode, false).local) { .fetch, .record_deletion, .touch_mtime => .land, // remote wins, or a permitted metadata-only update (r14) .none => .no_op, // identical, local wins, or dry-run: never overwrite }; std.debug.assert((result == .not_authorized) == !may_write); return result; } /// §7.3-r19 — an accepted uvfile change clears the cached unversioned /// catalog hash; a rejection or no-op leaves it untouched. pub fn uvLandingClearsCatalog(outcome: UvLanding) bool { return outcome == .land; } // --------------------------------------------------------------------------- // Section 9.1.1 — Unversioned-File Synchronisation // --------------------------------------------------------------------------- /// §9.1.1 L2064-2067 — a local unversioned-file row as classification sees /// it. A null hash marks a locally recorded deletion. `size`/`encoding`/ /// `content` belong to the storage layer and play no part in status or /// catalog-hash math, so they are not carried here. pub const UvRow = struct { name: []const u8, mtime: i64, hash: ?[]const u8, }; /// §9.1.1 L2095-2113 — the five-way (0..5) unversioned status code. pub const UvStatus = enum(u3) { absent = 0, remote_wins = 1, older = 2, identical = 3, newer = 4, local_wins = 5, }; /// §9.1.1 L2133-2154 — the local-store effect of an (already revert-mapped) /// status. `fetch` queues uvgimme and drops the row that would be /// replaced; `record_deletion` stores the received mtime and clears /// hash/size/encoding/content; `touch_mtime` updates local mtime only. pub const UvLocalAction = enum { none, fetch, record_deletion, touch_mtime }; /// §9.1.1 L2156-2160 — the send-table effect of an (already revert-mapped) /// status. pub const SendTableOp = enum { remove, mtime_only, keep_full }; /// §9.1.1 L2115-2119 — revert/dry-run posture for one uvigot classification. pub const UvMode = struct { revert: bool = false, dry_run: bool = false, }; /// §9.1.1 — the combined outcome of classifying one uvigot row: the /// post-revert-map status plus both of its effects, computed together so /// the revert map is applied exactly once (never for one effect but not /// the other). pub const UvDecision = struct { status: UvStatus, local: UvLocalAction, send_table: SendTableOp, }; /// §9.1.1 L2083-2086 — the per-request catalog-emission dedup latch; one /// instance covers one request. pub const ServerUvRequest = struct { catalog_emitted: bool = false, }; /// §9.1.1 L2095-2113 steps 1-5 — the raw status code, before revert /// mapping. `remote_hash` is the wire HASH (40/64 hex, or "-" for a /// remote deletion). pub fn uvStatus(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8) UvStatus { std.debug.assert(remote_hash.len >= 1 and remote_hash.len <= 64); const l = local orelse return .absent; if (l.hash) |h| std.debug.assert(h.len <= 64); const local_hash: []const u8 = l.hash orelse "-"; const mtime_cmp = std.math.order(l.mtime, remote_mtime); const hash_cmp = std.mem.order(u8, local_hash, remote_hash); const result: UvStatus = if (hash_cmp == .eq) switch (mtime_cmp) { .lt => .older, .eq => .identical, .gt => .newer, } else if (mtime_cmp == .lt or (mtime_cmp == .eq and hash_cmp == .lt)) .remote_wins else .local_wins; std.debug.assert(@intFromEnum(result) <= 5); return result; } /// §9.1.1 L2115-2117 — revert mode remaps 4->2 and 5->1; every other /// status is unchanged. fn revertMap(s: UvStatus) UvStatus { return switch (s) { .newer => .older, .local_wins => .remote_wins, else => s, }; } /// §9.1.1 L2156-2160 — the send-table effect of an already-mapped status; /// a pull-only session never keeps an upload queued. fn sendTableOp(status: UvStatus, pull_only: bool) SendTableOp { const result: SendTableOp = switch (status) { .absent, .remote_wins, .older, .identical => .remove, .newer => if (pull_only) .remove else .mtime_only, .local_wins => if (pull_only) .remove else .keep_full, }; std.debug.assert(!pull_only or (result != .mtime_only and result != .keep_full)); return result; } /// §9.1.1 L2133-2154, L2118-2119 — the local-store effect of an /// already-mapped status. `remote_is_deletion` is HASH == "-" on the wire; /// dry-run suppresses every local change. fn uvLocalAction(status: UvStatus, remote_is_deletion: bool, dry_run: bool) UvLocalAction { if (dry_run) return .none; const result: UvLocalAction = switch (status) { .absent => if (remote_is_deletion) .none else .fetch, .remote_wins => if (remote_is_deletion) .record_deletion else .fetch, .older => .touch_mtime, .identical, .newer, .local_wins => .none, }; std.debug.assert(!dry_run or result == .none); return result; } /// §9.1.1 — the deep composed interface: raw status, revert-mapped at most /// once, then both its local-store and send-table effects computed over /// the mapped value. pub fn uvDecide(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8, mode: UvMode, pull_only: bool) UvDecision { const raw = uvStatus(local, remote_mtime, remote_hash); const status = if (mode.revert) revertMap(raw) else raw; const remote_is_deletion = std.mem.eql(u8, remote_hash, "-"); const decision: UvDecision = .{ .status = status, .local = uvLocalAction(status, remote_is_deletion, mode.dry_run), .send_table = sendTableOp(status, pull_only), }; std.debug.assert(!mode.dry_run or decision.local == .none); std.debug.assert(@intFromEnum(decision.status) <= 5); return decision; } /// §9.1.1 L2061-2063 — pure civil-from-unix conversion (UTC, no leap /// seconds), matching SQLite's `datetime(mtime,'unixepoch')` output /// exactly: fixed-width `YYYY-MM-DD HH:MM:SS`. Civil-date math is /// Hinnant's `civil_from_days` (proleptic Gregorian, floor division /// throughout so negative `mtime` stays correct) — never `std.time`, so /// the result is a pure function of `mtime`. fn formatUvDatetime(mtime: i64, buf: *[19]u8) []const u8 { const secs_per_day: i64 = 86400; const days = @divFloor(mtime, secs_per_day); const secs_of_day = @mod(mtime, secs_per_day); std.debug.assert(secs_of_day >= 0 and secs_of_day < secs_per_day); // Hinnant civil_from_days: days since 1970-01-01 -> proleptic Gregorian // y/m/d (http://howardhinnant.github.io/date_algorithms.html). era/doe // split uses floor division so doe always lands in [0, 146096], // keeping every later division non-negative regardless of sign(days). const z = days + 719468; const era = @divFloor(z, 146097); const doe = z - era * 146097; // [0, 146096] const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365); // [0, 399] const y = yoe + era * 400; const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); // [0, 365] const mp = @divTrunc(5 * doy + 2, 153); // [0, 11] const d = doy - @divTrunc(153 * mp + 2, 5) + 1; // [1, 31] const m = mp + (if (mp < 10) @as(i64, 3) else @as(i64, -9)); // [1, 12] const year = y + (if (m <= 2) @as(i64, 1) else @as(i64, 0)); std.debug.assert(year >= 0 and year <= 9999); std.debug.assert(m >= 1 and m <= 12); std.debug.assert(d >= 1 and d <= 31); const hours = @divTrunc(secs_of_day, 3600); const minutes = @divTrunc(@mod(secs_of_day, 3600), 60); const seconds = @mod(secs_of_day, 60); // Written field-by-field (no std.fmt.bufPrint) so there is no fallible // path at all — width is fixed and every value is pre-bounded above. writeFixedDigits(buf[0..4], @intCast(year)); buf[4] = '-'; writeFixedDigits(buf[5..7], @intCast(m)); buf[7] = '-'; writeFixedDigits(buf[8..10], @intCast(d)); buf[10] = ' '; writeFixedDigits(buf[11..13], @intCast(hours)); buf[13] = ':'; writeFixedDigits(buf[14..16], @intCast(minutes)); buf[16] = ':'; writeFixedDigits(buf[17..19], @intCast(seconds)); return buf[0..19]; } /// Writes `value` as `buf.len` zero-padded ASCII digits, most significant /// first. `value` must fit in `buf.len` digits (asserted, not truncated). fn writeFixedDigits(buf: []u8, value: u32) void { var v = value; for (0..buf.len) |k| { const i = buf.len - 1 - k; buf[i] = '0' + @as(u8, @intCast(v % 10)); v /= 10; } std.debug.assert(v == 0); } /// §9.1.1 L2061-2067 — the unversioned catalog hash: SHA-1 [RFC3174] over, /// in name order, `NAME " " DATETIME " " HASH "\n"` for every row whose /// hash is not null (a deleted row is excluded, L2064-2067). `rows` must /// already be name-sorted — the store owns that ordering, not re-derived /// here. pub fn uvCatalogHash(rows: []const UvRow) HashHex { var hasher = std.crypto.hash.Sha1.init(.{}); var prev: ?[]const u8 = null; for (rows) |r| { std.debug.assert(r.name.len <= max_line_len - 1); if (prev) |p| std.debug.assert(std.mem.order(u8, p, r.name) != .gt); prev = r.name; const h = r.hash orelse continue; std.debug.assert(h.len <= 64); var dt_buf: [19]u8 = undefined; const dt = formatUvDatetime(r.mtime, &dt_buf); hasher.update(r.name); hasher.update(" "); hasher.update(dt); hasher.update(" "); hasher.update(h); hasher.update("\n"); } var digest: [20]u8 = undefined; hasher.final(&digest); const hex = sha1FinalHex(digest); for (hex) |c| std.debug.assert((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f')); return hex; } /// §9.1.1 L2083-2086 — wraps `serverUvResponse` with the per-request /// duplicate-catalog latch: once a catalog has gone out this request, a /// later call on the same `state` yields `.none` regardless of inputs. pub fn serverUvCatalog(state: *ServerUvRequest, match: bool, has_read: bool, may_upload: bool) UvResponse { const already = state.catalog_emitted; const result: UvResponse = if (already) .none else serverUvResponse(match, has_read, may_upload); state.catalog_emitted = already or (result != .none); std.debug.assert(!already or result == .none); return result; } /// §3.1 L374-399, §9.1.1 L2054-2056 — the client's per-session unversioned /// send table: NAME -> mtimeOnly. Owns duped NAME keys, so entries outlive /// the per-round wire buffers that named them (mirrors `RoundTables`). pub const UvSendTable = struct { map: std.StringHashMapUnmanaged(bool) = .empty, /// §9.1.1 L2054-2056 — session start: every local row whose hash is /// not null seeds the table with mtimeOnly false. pub fn initFromLocal(gpa: std.mem.Allocator, rows: []const UvRow) error{OutOfMemory}!UvSendTable { var self: UvSendTable = .{}; errdefer self.deinit(gpa); try self.map.ensureUnusedCapacity(gpa, @intCast(rows.len)); for (rows) |r| { if (r.hash == null) continue; std.debug.assert(r.name.len <= max_line_len - 1); if (self.map.contains(r.name)) continue; const owned = try gpa.dupe(u8, r.name); self.map.putAssumeCapacity(owned, false); } return self; } pub fn deinit(self: *UvSendTable, gpa: std.mem.Allocator) void { var it = self.map.keyIterator(); while (it.next()) |key| gpa.free(key.*); self.map.deinit(gpa); self.* = undefined; } /// §9.1.1 L2156-2160 — apply one status's send-table effect for NAME. pub fn apply(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, op: SendTableOp) error{OutOfMemory}!void { std.debug.assert(name.len <= max_line_len - 1); switch (op) { .remove => if (self.map.fetchRemove(name)) |kv| gpa.free(kv.key), .mtime_only => try self.put(gpa, name, true), .keep_full => try self.put(gpa, name, false), } std.debug.assert(self.contains(name) == (op != .remove)); } fn put(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, mtime_only: bool) error{OutOfMemory}!void { if (self.map.getPtr(name)) |v| { v.* = mtime_only; return; } try self.map.ensureUnusedCapacity(gpa, 1); const owned = try gpa.dupe(u8, name); self.map.putAssumeCapacity(owned, mtime_only); } pub fn contains(self: *const UvSendTable, name: []const u8) bool { return self.map.contains(name); } pub fn mtimeOnly(self: *const UvSendTable, name: []const u8) ?bool { return self.map.get(name); } pub fn isEmpty(self: *const UvSendTable) bool { return self.map.count() == 0; } pub fn count(self: *const UvSendTable) usize { return self.map.count(); } }; test "sync §9.1.1: uv-hash datetime segment is SQLite datetime(mtime,'unixepoch')" { var buf1: [19]u8 = undefined; try std.testing.expectEqualStrings("1970-01-01 00:00:00", formatUvDatetime(0, &buf1)); var buf2: [19]u8 = undefined; try std.testing.expectEqualStrings("2009-02-13 23:31:30", formatUvDatetime(1234567890, &buf2)); } test "sync §9.1.1: status 0 fetches (uvgimme + drop replaced) unless HASH is -" { try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.absent, false, false)); try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.absent, true, false)); } test "sync §9.1.1: status 1 fetches when HASH present, records deletion when -" { try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.remote_wins, false, false)); try std.testing.expectEqual(UvLocalAction.record_deletion, uvLocalAction(.remote_wins, true, false)); } test "sync §9.1.1: status 2 updates local mtime only" { try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, false, false)); try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, true, false)); } test "sync §9.1.1: status 3 makes no local change" { try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, false, false)); try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, true, false)); } test "sync §9.1.1: status 4 keeps send-table entry mtimeOnly true when uploads allowed" { try std.testing.expectEqual(SendTableOp.mtime_only, sendTableOp(.newer, false)); } test "sync §9.1.1: status 5 keeps/reinserts send-table entry mtimeOnly false when uploads allowed" { try std.testing.expectEqual(SendTableOp.keep_full, sendTableOp(.local_wins, false)); } test "sync §9.1.1: send-table update — statuses 0-3 remove, 4 mtimeOnly, 5 keeps full" { const Case = struct { status: UvStatus, want: SendTableOp }; const cases = [_]Case{ .{ .status = .absent, .want = .remove }, .{ .status = .remote_wins, .want = .remove }, .{ .status = .older, .want = .remove }, .{ .status = .identical, .want = .remove }, .{ .status = .newer, .want = .mtime_only }, .{ .status = .local_wins, .want = .keep_full }, }; for (cases) |c| try std.testing.expectEqual(c.want, sendTableOp(c.status, false)); } test "sync §9.1.1: pull-only removes statuses 4 and 5 from the send table" { try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.newer, true)); try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.local_wins, true)); } /// §9.1.1-uv1 — the receiver's answer to a uvgimme: uvfile carrying /// content when it fits the send-size budget, or uvigot re-announcing /// the same state when it does not. The budget check itself (§4 body /// cap) lives in the response assembler; this takes its verdict. pub const UvGimmeReply = enum { uvfile, uvigot }; /// §9.1.1-uv1, §7.3 — "The receiver answers with uvfile ... or with /// uvigot ... again if the send-size limit prevents including the /// content in that response." pub fn uvGimmeReply(content_fits: bool) UvGimmeReply { return if (content_fits) .uvfile else .uvigot; } // --------------------------------------------------------------------------- // Section 9.2.1 — Derived Branch, Tag, Leaf, Closed, and Current-Trunk Views // --------------------------------------------------------------------------- // // §9.2.1 defines no wire bytes: it delegates branch/tag/leaf/closed/trunk // views entirely to the artifact-format tag vocabulary and repostate's // derivation, and states the readiness precondition under which a receiver // may trust those views. Every mechanism it names already exists elsewhere // (Operator's catalog, repostate's crosslink/resolveName/currentTrunk); this // section is covered as cited tests over that existing structure, not new // production code. // --------------------------------------------------------------------------- // Section 9.3-9.5 — Round-loop cluster (binding over §3.3 / §3.7) // --------------------------------------------------------------------------- // // §9.3-9.5 restate no algorithm: they import the §3.3 phantom/igot/gimme // surface (self-declared authoritative, L599-604) and the §3.7 termination // rule. These tests bind that import over the already-committed predicates; // the only production addition is `PendingDeltaQueue.hasTarget`, the OPTION B // content-received derivation for the §3.3 dangling-delta path. // The default filter set: nothing excludes the phantom. // --------------------------------------------------------------------------- // Section 3.2 — Reply consumption (Client.consumeReply) fuzz corpus. The // Appendix A worked-exchange tests over consumeReply live in sync_test.zig; // the round-1 vector stays here only as raw bytes for the corruption fuzzer. // --------------------------------------------------------------------------- // §A.1 L3085-3090 — Round 1 reply, 367-byte body: server-version pragma, two // gimme cards, two igot cards, closing timestamp. The vector holds raw wire // bytes; the citation lives here, never inside the .txt. const a1_round1_reply = @embedFile("rfc_vectors/sync_a1_round1_reply.txt"); comptime { // The RFC states this body size directly (L3074); a transcription drift // trips here before any test runs. std.debug.assert(a1_round1_reply.len == 367); } // Emit one random card-shaped or garbage line into `out`; returns bytes // written (0 when there is no room). Menu spans every wire-parsing branch the // dispatcher gained: gimme/igot/clone_seqno/pragma/timestamp/message/file/ // private plus raw garbage. fn fuzzWriteLine(rand: std.Random, out: []u8) usize { if (out.len < 96) return 0; var hash: [64]u8 = undefined; for (&hash) |*c| c.* = "0123456789abcdef"[rand.intRangeLessThan(u8, 0, 16)]; const h = hash[0..(if (rand.boolean()) @as(usize, 40) else 64)]; const line: []u8 = switch (rand.intRangeLessThan(u8, 0, 11)) { 0 => std.fmt.bufPrint(out, "gimme {s}\n", .{h}) catch return 0, 1 => std.fmt.bufPrint(out, "igot {s}\n", .{h}) catch return 0, 2 => std.fmt.bufPrint(out, "igot {s} 1\n", .{h}) catch return 0, 3 => std.fmt.bufPrint(out, "clone_seqno {d}\n", .{rand.int(i32)}) catch return 0, 4 => std.fmt.bufPrint(out, "pragma server-version {d} 20260311 113146\n", .{rand.int(u16)}) catch return 0, 5 => std.fmt.bufPrint(out, "# timestamp 2026-07-02T18:31:13 errors {d}\n", .{rand.int(u8)}) catch return 0, 6 => std.fmt.bufPrint(out, "message {s}\n", .{h[0..8]}) catch return 0, // §7.5 — the private marker arm: bare (arity 1, valid) and argument- // carrying (arity >1, a §5.2 fatal abort). Both must be tolerated: a // declared error or a valid observation, never a trap. 8 => std.fmt.bufPrint(out, "private\n", .{}) catch return 0, 9 => std.fmt.bufPrint(out, "private {s}\n", .{h[0..rand.intRangeAtMost(usize, 1, 8)]}) catch return 0, 7 => blk: { const glen = rand.intRangeAtMost(usize, 0, 16); for (out[0..glen]) |*c| c.* = rand.int(u8); out[glen] = '\n'; break :blk out[0 .. glen + 1]; }, else => blk: { const size = rand.intRangeAtMost(u32, 0, 8); const head = std.fmt.bufPrint(out, "file {s} {d}\n", .{ h, size }) catch return 0; var w = head.len; var k: u32 = 0; while (k < size and w < out.len) : (k += 1) { out[w] = rand.int(u8); w += 1; } break :blk out[0..w]; }, }; return line.len; } fn fuzzReplyBody(rand: std.Random, buf: []u8) []const u8 { // Half the runs corrupt a copy of the round-1 reply (payload-independent // bit flips); the rest build a random card/garbage sequence. if (rand.boolean()) { const n = @min(buf.len, a1_round1_reply.len); @memcpy(buf[0..n], a1_round1_reply[0..n]); var flips = rand.intRangeAtMost(usize, 0, 6); while (flips > 0) : (flips -= 1) buf[rand.intRangeLessThan(usize, 0, n)] ^= rand.int(u8); return buf[0..n]; } var len: usize = 0; var lines = rand.intRangeAtMost(usize, 0, 12); while (lines > 0 and len < buf.len) : (lines -= 1) len += fuzzWriteLine(rand, buf[len..]); return buf[0..len]; } test "sync §3.2 fuzz: consumeReply tolerates adversarial reply bytes" { // The dispatcher must return only a valid RoundObservation or a declared // ConsumeError — never trap — on any wire input, and the reported // phantom_table_nonempty must always match the final phantom set (§3.7(a)). var prng = std.Random.DefaultPrng.init(0x5CA1_AB1E_D00D_1E5E); const rand = prng.random(); var buf: [1024]u8 = undefined; var iter: usize = 0; const iterations = 4000; while (iter < iterations) : (iter += 1) { const body = fuzzReplyBody(rand, &buf); var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| switch (e) { error.OutOfMemory, error.FatalProtocolError, error.NotImplemented => continue, }; try std.testing.expectEqual(client.phantoms.count() > 0, obs.phantom_table_nonempty); // §7.5 L2359 — private content is only ever bound by a content card, so a // delivered private marker implies versioned content this round. if (obs.private_content_delivered) try std.testing.expect(obs.versioned_content_delivered); } }