Artifact 8056535052758849232fa99d57d7c1f587adff347dcc7c3bbb20cb8aa5088034:
- File
src/sync.zig
— part of check-in
[43c89e841f]
at
2026-08-06 12:33:27
on branch sub-manifest
— citations: check every range under a section, not just the first
A citation may carry several ranges: `§9.3 L3278/L3320-3322`, `§7.7 L1465/L1469-1471`, `§4.6.4-m6 (L1014)`. Matching only `§SEC Lnnn` saw the first and was blind to the rest, so half a compound citation could be re-keyed while the other half stayed rotted -- on the same line, looking equally verified. An adversarial reviewer caught that during a re-key pass; the tool could not have.
342 line references sat outside the checker's view. Each now binds to the NEAREST PRECEDING section on its own line, which is the rule that makes this safe: a range written BEFORE any section on the line -- `L545-547 hands clone to §8.1-§8.4, and §8.2 L1504-1506` -- belongs to earlier prose, not to a section further right, and is left alone rather than misattributed. 122 became checkable; 12 leading ranges were correctly skipped. Ranges are never carried across a line break for the same reason.
This also surfaced §10.5, which the old pattern could not see at all because a requirement id sits between section and range (`§10.5 r43 L2477-2479`). §10 is Error Handling with only §10.1; the send-private and client-url claims are §9.5 Private-Content and Discovery Pragmas (L1983-1985: "send-private enables private sync only with x ... Clients repeat it every private round"). Same -1 downshift already verified for §10.3 and §10.4. 21 sites renumbered, and not-a-section is back to 0.
Baseline 80 to 173: 93 defects that were always there and are now visible. The population it measures grew 821 to 943.
test 3495/3495, fmt. Issue #18. (user: dmestas, size: 458214) [annotate] [blame] [check-ins using]
//! 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 L1079-1080). Sans-IO: the /// plaintext password never lives here, only the derived shared secret. credential: ?Credential = null, /// §5.3 L1057-1058 — 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 L1537-1547 — 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 L312-313, §3.2 L412-413 — 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 L567-568 — 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 L1351-1360 — 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 L1376-1377 — 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" — §9.5 only accepts send-private /// when the client holds the capability. private_granted: bool = false, /// draft-fossil-submanifests-00 §7.2 — set once the server has sent the /// `avoid-submanifests` pragma: "a client receiving it MUST NOT emit S /// cards in commits created during the session." Per-session and one-way, /// exactly like `push_cleared`: a round that carries no pragma never clears /// it, and a fresh Client (new session) starts clear. Read through /// `clientMayEmitSCards` together with the §8.1 repository setting. avoid_submanifests: bool = false, /// submanifests §8.1 — this repository's `enable-submanifests` setting, the /// client-side twin of `Server.submanifests_enabled`: default false, arms /// the §7.2 gate on this client's UPLOAD path (push/sync) and raises the /// version it announces. Off leaves every byte this client puts on the wire /// exactly as it was before this draft. submanifests_enabled: bool = submanifest_emission_default, /// submanifests §8.1 — this repository's durable seen-submanifest indicator, /// the SECOND thing that arms the §7.2 upload gate. See /// `Server.seen_submanifest` for why the setting alone is not enough. seen_submanifest: bool = false, /// submanifests §7.2 — artifacts this session declined to upload because the /// peer's announced version predates sub-manifest support. Session-wide and /// monotonic, like `content_sent`. Non-zero is a failed run, not a warning: /// the draft rules out silent omission, so a push that withheld an artifact /// MUST NOT report success. submanifests_refused: usize = 0, /// submanifests §7.2 — the rids this session has already refused, so a peer /// that re-`gimme`s one is not answered by re-queueing it. /// /// Not bookkeeping: without it the session LIVELOCKS. A refused artifact is /// drained from the send queue and never sent, the peer therefore still /// lacks it and gimmes it again next round, and a fresh queue append is /// §3.7(b) "content queued this round" — so the exchange continues forever /// and a push against a pre-S peer ends in `RoundLimitExceeded` instead of a /// clean terminal with a counted refusal. Skipping the re-queue makes the /// refusal settle: the round after the refusal queues nothing, §3.7 stops, /// and `submanifests_refused` is what the operator sees. /// /// It also makes the count DISTINCT ARTIFACTS rather than distinct rounds, /// which is the number §7.2 wants reported. Cleared whenever the peer /// re-announces a version (see `consumeReply`). submanifests_withheld: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty, /// submanifests §7.3/§9.4 — the third deferred-edge kind. §9.4: "Deferred- /// processing cascades typically know two edge kinds: delta-manifest /// baselines and content delta sources. The S reference is a third. An /// implementation MUST record, when a tree manifest's processing is deferred /// (Section 7.3), enough to rediscover that manifest when the missing /// sub-manifest arrives, and its dephantomization path MUST traverse that /// record." /// /// `pending` is zit's record for the two OLDER kinds; this is the third, /// kept beside it and traversed at the same point (`noteSubManifestArrival`, /// on every dephantomization). Optional and opened lazily: the record needs /// the store and an allocator, neither of which a `Client` literal has, and /// a session that never receives an S card never allocates it — which is /// every session against a repository predating this draft. tree_deferrals: ?repostate.TreeDeferrals = null, /// §9.3 / submanifests §6.3/§8.1 — the three project-scope sub-manifest /// settings latched off the peer's scalar `config /config` card, for the CLI /// to adopt into a FRESH clone after the run (the exact shape /// `peer_project_code_buf` already has for §9.1's shared identity). /// /// Latch only: this layer never decides what to do with them. Only the /// clone verb applies them, which is what keeps clone-time adoption from /// leaking into pull/sync — a later pull against a repository whose operator /// re-pinned k locally cannot silently clobber that pin, because no code /// path outside `runClone` reads this field. Ongoing refresh would need real /// MTIME conflict resolution, and zit's config frame stores no per-field /// write time (see `submanifest_config_mtime`); that is a separate piece of /// work, deliberately not smuggled in here. peer_submanifest: SubmanifestConfig = .{}, /// §3.3 L527-530, §8.8 L1777-1779 — the repository shun table the per-round /// phantom scan consults. Borrowed for the session's life and never written /// here; the CLI supplies the durable set and a bare `Client` gets the empty /// one, so a construction site that names none behaves as it did before the /// filter existed. Read on every scan, never memoised: freshness is the /// implementation's business, not the protocol layer's. shun: ShunSet = .none, /// §7.7 L1428-1432 — "shun-sha1 applies exactly when the receiving /// repository's effective hash-policy value at the start of processing that /// card is the shun-sha1 value defined by [FossilArtifact], Section 7. No /// peer state or artifact-transfer card field participates in this /// predicate." So it is a property of THIS repository, carried per session /// and read per card — the other half of `admitInbound`'s combined target /// predicate, alongside `shun`. The CLI supplies the repository's stored /// value; a bare `Client` gets `.auto`, whose only §7.7-relevant property is /// that it is not shun-sha1, so a construction site that names none behaves /// exactly as it did before this field existed. hash_policy: artifact.HashPolicy = .auto, /// §7.5 L1379-1383 — 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.submanifests_withheld.deinit(gpa); self.pending.deinit(gpa); if (self.tree_deferrals) |*td| td.deinit(); // submanifests §7.3/§9.4 self.* = undefined; } /// submanifests §5.2/§7.3 — how many received check-ins still have at least /// one recorded S-reference gap: the count of trees this session may NOT /// present as a complete file set. Distinct from `unresolvedPhantoms`, which /// counts MISSING HASHES; §9.4's record is keyed by DEPENDENT, which is the /// identity an operator (and a retry) needs. Zero for every session that /// received no S card, so a run against a repository predating this draft /// reports exactly what it reported before. pub fn deferredTrees(self: *const Client) usize { if (self.tree_deferrals) |*td| return td.deferredCount(); return 0; } /// §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]; } /// §9.3 — the three sub-manifest settings this session heard from the peer, /// all-null when it sent none (an older peer, or one that ignored the /// request). Owns no memory: the wire slices were unquoted into scalars at /// receipt, so this survives the round boundary that frees the reply bytes. pub fn peerSubmanifestConfig(self: *const Client) SubmanifestConfig { return self.peer_submanifest; } /// §3.1 L312-313, §3.2 L412-413 — 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.3 r24 — how many artifacts the peer announced (igot, or an M card of /// a received cluster) that this side still lacks. Live, not cumulative: an /// entry stops counting the moment its content lands, because the store — /// not the phantom table — is the authority on what is held. A run that /// reaches §3.7's clean terminal with a nonzero count converged on nothing /// it was told about, which is the caller's cue to refuse to report success. /// submanifests §7.2 — the upload decision for one outbound artifact, given /// its plaintext. The client mirror of `Server.submanifestSendGate`, with /// the same two guards in the same cost order: the §8.1 arming indicator /// first (so a repository that has never held a gated artifact never touches /// `bytes`, and its uploads are what they were before this draft), then the /// peer's announced version. /// /// `self.peer.version` is the SERVER's announced release integer (§10.1 r13) /// — the peer's, which is what §7.2 reads. The field default is 0, §10.1 /// r10's "nothing announced yet" value rather than r14's own-version seed, /// and it is deliberately the pre-S value: an upload happens only in answer /// to a `gimme`, which arrives in a reply, and every zit request carries /// DATE/TIME (`serverEmitsServerVersion`), so the server's announcement has /// been recorded before the first artifact is ever queued. A peer that never /// announced is treated as pre-S, which is the safe direction and matches /// the server half. /// submanifests §7.2/§8.1 — is the upload gate armed? The client half of /// `Server.submanifestGateArmed`, same disjunction and same reason. fn submanifestGateArmed(self: *const Client) bool { return self.submanifests_enabled or self.seen_submanifest; } fn submanifestSendGate(self: *const Client, bytes: []const u8) GateDecision { if (!self.submanifestGateArmed()) return .emit; // §7.2's gated set is "of type SUBMANIFEST, OR a manifest carrying an S // card" — two shapes, not one. `transferGatedArtifact` is that whole // disjunction; an S-card scan alone would let every LEAF sub-manifest // (F cards, no S card, §4.4) past a gate that refused its tree. return submanifestPeerVersionGate(self.peer.version, repostate.transferGatedArtifact(bytes)); } /// /// A SHUNNED phantom does not count. §3.3 L527-530 excludes its name from /// the scan, so this run deliberately never asked for it: it is a phantom /// that was told about and refused, not one that was asked for and withheld. /// Counting it would make `zit pull` report failure — permanently, since the /// peer re-announces the hash every round — for doing exactly what the /// operator's shun row instructed. The phantom itself stays (creation is /// unconditional on the shun table, §3.3 r24(1) L511-514); only the /// unmet-expectation tally excludes it. pub fn unresolvedPhantoms(self: *const Client, store: repostate.ArtifactStore) usize { var unresolved: usize = 0; var it = self.phantoms.keyIterator(); while (it.next()) |rid| { if (store.held(rid.*)) continue; if (self.shun.contains(store.hashOf(rid.*))) continue; unresolved += 1; } std.debug.assert(unresolved <= self.phantoms.count()); return unresolved; } /// §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 L1587-1594 clone announces none), a /// `gimme` per eligible phantom, and a `file` per queued send /// (§3.2 L397-399). `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. // // submanifests §7.2 — the announced release integer states what this // implementation can READ: the §7.2 gate reads the PEER's announcement, // so a peer that understands sub-manifests must say so or a conforming // sender will refuse it every tree. `announcedVersion` is raise-only and // turns on reader support, never on §8.1's emission setting — see its // doc comment for why conflating the two livelocks the feature. const announced = VersionInfo{ .version = announcedVersion(self.version.version, submanifest_reader_support), .datetime = self.version.datetime, }; const pragma = writeClientVersion(announced, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, out, pragma); // §6.1 L1073-1081 / §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 L397-399 — 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 L1587-1594), /// `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 L383-386 — 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 L1541-1547), 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 L1541-1547 — no operation card. §9.3 "Clone defers // requests" / §9.3 r22 — this round is where a clone asks // for configuration, and the ONLY round any zit client ever // does: adoption is clone-time (see `peer_submanifest`). // A peer that ignores the request answers with no config // card, which §9.3 makes a silent, error-free no-op. const round: u64 = if (self.state == .clone_round0) 0 else 1; std.debug.assert(sendableAfterCloneRound0(self.operation, round)); const card = writeReqconfig(config_scalar_marker, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); }, .stop => unreachable, // a request is composed only while §3.7 continues }, } const count: repostate.Rid = store.count(); // §3.2 L397-398, §A.1 L2449-2453 — a pull/push/sync request announces every // held artifact with igot: §8.5 L1633-1634 makes the announcement live // (a server creates its phantoms from a client igot only under push). // §8.2 L1587-1594 — 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 L1587-1590 // builds solely from the RID-cursor cfile sequence. The §8.2 L1591-1594 // 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 L397-398, §3.3 L527-530 — 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 filter (§3.3 (a)) is empty when composing a request: no // peer gimme has been observed for this prospective round. The shun // filter (§3.3 (b), L527-530) is NOT empty — it is the repository's // durable shun table, asked here on every scan and never memoised, so // whatever the injected set is willing to re-derive is honoured on the // next scan (the set itself decides that; see `ShunSet`). 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, // §3.3 L527-530 — "the ordinary phantom scan excludes … // names in the repository shun table". Asked with the // COMPLETE wire name the store holds, never a prefix. .shunned = self.shun.contains(store.hashOf(rid)), .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 L397-398, §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; // submanifests §7.2 — the upload half of the send gate, on the // same "uploading composes from plaintext" path the server's // half sits on. The refusal is counted rather than turned into // an outbound `error` card: an `error` card in a REQUEST is // outside the §3.9 strict-accept request vocabulary and would be // answered `bad command`, aborting the round. §7.2 forbids // SILENT omission; `submanifests_refused` is what makes it // un-silent — the CLI reports it and fails the run, so the // operator learns the artifact was withheld and why. if (self.submanifestSendGate(content) == .refuse) { const gop = try self.submanifests_withheld.getOrPut(gpa, send_rid); if (!gop.found_existing) self.submanifests_refused += 1; 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 L1057-1058 — 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 L1358-1360 — 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| { const was = self.peer.version; recordPeerVersion(&self.peer, v); // submanifests §7.2 — a peer that RAISES its version // gets a fresh gate answer, so forget what was withheld // from it before. The trigger is the change, not the // announcement: a pre-S peer re-announces the same // version in every reply, and clearing on each one // re-offers artifacts it has already refused. Since // this set is what makes the exchange terminate at all // (see its use in the send decision below), clearing it // every round is a livelock, not a lost optimisation. if (self.peer.version != was) { self.submanifests_withheld.clearRetainingCapacity(); } } else |_| {} } else if (tokens.len >= 2 and parsePragmaName(tokens.items[1]) == .@"avoid-submanifests") { // submanifests §7.2 — latch the server's refusal for the // session; §3.5's tolerate-and-ignore still covers every // other unrecognised name. self.avoid_submanifests = true; } 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 L1362-1363 — 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 L1530-1533 — 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 L1376-1377 — 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; } // §7.7 L1437-1440 — "A non-clone file whose target is shunned is // consumed and discarded after its payload and pending private // marker; it emits no error, does not set content-received, and // makes no repository mutation. A clone file bypasses this // combined target check." Both halves are here: the guard is on // `operation != .clone` (the tree's clone-context predicate, set // at session init and true across every clone batch — see // `receiveDelta`'s clone fast path), and the discard is an early // return past both the delta and the store arms, so not even the // `intern` a `put` would do lands. // // The ASYMMETRY with `cfile` below is deliberate and is what the // applied text says; do not "restore" a symmetric rule. // // Placement: after the marker (taken at handler entry above) and // after the payload framing, exactly as "after its payload and // pending private marker" requires. `private_content_discarded` // is NOT set — that flag is §7.5's record of a §7.5 discard, and // §7.7 asks for no observation at all. if (!self.targetAdmission().admits(fc.hash, false)) 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); try self.absorbCluster(gpa, store, obs, payload.bytes); // submanifests §7.1 — a stored S-bearing artifact phantomizes // each absent sub-manifest it references. try self.absorbSubManifestRefs(gpa, store, obs, store.hashOf(rid), payload.bytes, was_private); // submanifests §9.4 — …and this arrival may itself be the // sub-manifest some earlier check-in was deferred on. try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(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 L1352-1353 // §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), self.targetAdmission()) 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 L1374-1376 — 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 L1379-1383) 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 L532 — 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 L412-413 / §3.3 L532-535 — if this side holds HASH and // the operation direction permits sending, queue its rid in the // per-session send buffer (§3.1 L312-313) 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 L412-413 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. // submanifests §7.2 — an artifact this session already // refused is not re-queued. Re-queueing it would be a fresh // §3.7(b) content-queued observation every round, so the // exchange would never terminate (see `submanifests_withheld`). if (gimmeResponse(holds, self.pushActive(), true) == .send and !self.submanifests_withheld.contains(rid)) { const gop = try self.send_queue.getOrPut(gpa, rid); if (!gop.found_existing) obs.content_queued = true; } } return payload_start; }, .clone_seqno => { // §8.2 L1521-1525, §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 L976-978) the client aborts on (§5.2 // L1809-1812, §3.9 L797-798): 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 L567-568, §4.3 — the server MAY emit a cookie; the // client MUST store the opaque TEXT, replacing any held one. // §B L2835 `cookie-card = "cookie" SP fossilized LF`: a bare // `cookie` with no TEXT fails the card's arity check, a fatal // client abort (§5.2 L1024-1027). §3.9 L797-802 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 L1073-1081 (login is a request-only card, "carries // exactly one login card") / §9.3 L1882-1886 (reqconfig is // client→server, "A client sends reqconfig NAME"). Either in a // server reply is a strict-accept violation (§3.9 L797-798: // "a receiver applies a defined rule or aborts"). return self.abort(); }, .config => { // §9.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(); // §9.3/§9.3 r24 — the scalar marker is the only group whose // payload this implementation reads; a table group (r28) is // framed and skipped exactly as it was before. The three // sub-manifest settings are latched here and applied nowhere in // this layer — see `peer_submanifest` for why the split matters. if (configKind(cc.groupname) == .scalar) { const got = parseSubmanifestConfigPayload(p.bytes); if (got.enable) |v| self.peer_submanifest.enable = v; if (got.k) |v| self.peer_submanifest.k = v; if (got.auto_retune) |v| self.peer_submanifest.auto_retune = v; } return p.next; }, .cfile => { // §7.5 L1362-1363 — 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 L937-939 — 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 L1530-1533 — 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 L1365-1367 — 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 } // §7.7 L1440-1444 — "For cfile, the private-admission behavior of // Section 7.5 runs first; if processing continues, a shunned // target is consumed and discarded after its payload even in // clone, and its raw parser artifact-card count nevertheless // increments after handler invocation." // // All three clauses are satisfied by this line's POSITION, not by // new code: // * "§7.5 runs first" — the private discard above returns, so // sitting below it IS the ordering; // * "even in clone" — no `operation` guard here, unlike the // `file` arm above (L1440's clone bypass is for `file` only); // * "the count nevertheless increments" — `artifact_count_grew` // was set above, before the store/discard split, so the // §3.7(e) clone continuation still advances and no second // increment belongs here. if (!self.targetAdmission().admits(cc.hash, 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 L1352-1353 // §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, }; // §3.3 r24 — the wire bytes are a §4.1 container, so cluster // membership is readable only after an inflate. Inflate into a // scratch buffer freed on the spot rather than through // `store.content`, which would memoise the plaintext and cost // the §7.2 wire-backed peak its O(wire) bound. Gated so a clone // — which absorbs no cluster — never pays the inflate at all. A // container that will not inflate announces nothing: §7.2/§7.7 // defer that verdict to the read path, not to this round. // // submanifests §7.1 is NOT satisfied by this branch on a clone, // and deliberately is not attempted here: it needs the same // plaintext, and a clone has none. §7.1 is a duty of STORING, so // a clone discharges it where it actually stores — the persist // commit hook (`cmd/sync.phantomizeSubManifestRefs`), which is // already holding each artifact's reconstructed plaintext for // the §7.7 verify. That keeps the clone's O(wire) receive bound // exactly as it is while still minting a phantom for every // sub-manifest the CLONED PEER did not itself hold. if (self.clusterAnnouncementsApply()) { if (codec.decode(gpa, payload.bytes, reply_body_cap)) |plain| { defer gpa.free(plain); try self.absorbCluster(gpa, store, obs, plain); // submanifests §7.1, on the plaintext this branch // already paid to inflate. try self.absorbSubManifestRefs(gpa, store, obs, store.hashOf(rid), plain, was_private); } else |err| switch (err) { error.OutOfMemory => return ConsumeError.OutOfMemory, error.TruncatedHeader, error.InvalidStream, error.OutputTooLarge => {}, } } // submanifests §9.4 — the third edge kind, traversed beside the // two older ones below. Unlike the cluster/S-run absorb above, // this is NOT gated on `clusterAnnouncementsApply`: a clone // stores the very sub-manifest an earlier check-in was deferred // on, and the record must retire whoever asks for it. try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid)); // §7.4 — a full-content cfile may complete a retained delta. self.pending.completeFor(gpa, store, store.hashOf(rid), self.targetAdmission()) 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 L1351-1353 — 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 L1024-1027, §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 L1362-1363 — 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). /// §3.3 r24 / §8.5 L1633-1635 — fold a received cluster's membership into /// the phantom set. A cluster (§4.5.2: M cards, closed by its Z checksum) /// is the bulk form of `igot`: once a server folds an artifact into a /// cluster it stops announcing that artifact individually, so the M cards /// ARE the announcement. Without this the §3.7(a) continuation reads an /// empty phantom table after the cluster lands and terminates a pull that /// fetched nothing — silently, because every card was well-formed. /// /// A clone is exempt for the same reason it announces no igot (§8.2 /// L1417-1427): it converges on the RID-cursor cfile sequence, never on /// gimme, so cluster-derived phantoms would only bloat its requests. /// /// Content that is not a cluster leaves the client untouched. Membership is /// validated in full before any of it is applied, so a malformed artifact /// that merely opens with an M card mints no phantom. fn clusterAnnouncementsApply(self: *const Client) bool { return self.operation != .clone and igotCanObtain(.client, self.operation); } fn absorbCluster( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, obs: *RoundObservation, content: []const u8, ) error{OutOfMemory}!void { if (!self.clusterAnnouncementsApply()) return; // §4.5.2 — cards are emitted in letter order, so a cluster opens with M // and nothing else does. Two cheap bytes reject every other artifact // type before a line is scanned. if (content.len < 2 or content[0] != 'M' or content[1] != ' ') return; // Pass 1: every line must be a well-formed M or Z card. var cursor: usize = 0; while (cursor < content.len) { const end = std.mem.indexOfScalarPos(u8, content, cursor, '\n') orelse return; const card = artifact.tokenizeCardLine(content[cursor .. end + 1]) orelse return; switch (card.letter) { 'M' => if (artifact.parseMCard(card) == null) return, 'Z' => {}, else => return, // §4.5.2 — a cluster admits M and Z only } cursor = end + 1; } std.debug.assert(cursor == content.len); // Pass 2: each unheld member becomes a phantom, exactly as the `igot` // announcing it would have (§3.3 r25). cursor = 0; while (cursor < content.len) { const end = std.mem.indexOfScalarPos(u8, content, cursor, '\n').?; const card = artifact.tokenizeCardLine(content[cursor .. end + 1]).?; cursor = end + 1; if (card.letter != 'M') continue; const member = artifact.parseMCard(card).?.hash; const rid = try store.intern(member); if (igotDisposition(.client, self.operation, store.held(rid)) != .create_phantom) continue; const gop = try self.phantoms.getOrPut(gpa, rid); if (!gop.found_existing) { gop.value_ptr.* = .{}; obs.phantom_created = true; } } } /// draft-fossil-submanifests-00 §7.1 — "Storing a structural artifact that /// carries S cards whose referenced sub-manifests are absent MUST create a /// phantom for each missing reference, with the referencing artifact's /// privacy classification, exactly as an absent delta source creates a /// source phantom." This is that rule, at the one place a client stores a /// received artifact. /// /// Structurally the sibling of `absorbCluster`: same validate-then-apply /// discipline (an artifact that merely opens with an S card mints nothing /// unless its whole card sequence tokenizes), and the same phantom-set /// disposition through `igotDisposition`. It differs in one deliberate way: /// the STORE phantom is interned unconditionally, because §7.1's MUST is /// about repository state, while entry into `self.phantoms` — the §3.3 /// gimme set — keeps the operation-dependent gate a clone needs (§8.2: a /// clone converges on the cfile cursor, never on gimme). /// /// `repostate.sBearingCandidate` is the fast reject: every artifact that /// predates this draft fails it before a line is scanned, so no existing /// repository pays for this. §7.1's "with the referencing artifact's /// privacy classification" is carried by `private`, exactly as the DELTASRC /// phantom carries it in `receiveDelta`. fn absorbSubManifestRefs( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, obs: *RoundObservation, /// §7.3/§9.4 — the wire name of the artifact whose S run this is: the /// DEPENDENT the gap record is keyed by, and the identity §9.4 requires /// be rediscoverable when the missing sub-manifest arrives. Null only /// where the caller has no name to give (no such caller exists today; /// the parameter is explicit so a future one cannot silently skip it). dependent: ?[]const u8, content: []const u8, private: bool, ) error{OutOfMemory}!void { // The card scan itself is `repostate.collectSubManifestRefs` — one // extractor shared with the persist commit hook, so §7.1's two storing // boundaries cannot drift apart. It returns false for a malformed card // run, having appended nothing: validate-then-apply, so a half-read // artifact never leaves a half-minted phantom set behind. var refs: std.ArrayListUnmanaged([]const u8) = .empty; defer refs.deinit(gpa); if (!try repostate.collectSubManifestRefs(gpa, content, &refs)) return; for (refs.items) |hash| { const rid = try store.intern(hash); // §7.1: the store phantom is unconditional // §7.3/§9.4 — an ABSENT reference defers this artifact's processing: // "record the gap keyed by dependent and missing hash, idempotently, // and retry on arrival." Recorded regardless of the gimme-set gate // below, because §7.3's record is about repository state, not about // which operation asks for content: a clone converges on the cfile // cursor and creates no gimme phantom, yet the check-in it just // stored is every bit as deferred. if (!store.held(rid)) { if (dependent) |dep| { const td = try self.treeDeferrals(gpa, store); try td.deferProcessing(dep, hash); } } if (igotDisposition(.client, self.operation, store.held(rid)) != .create_phantom) continue; const gop = try self.phantoms.getOrPut(gpa, rid); if (!gop.found_existing) { gop.value_ptr.* = .{}; obs.phantom_created = true; } if (private) gop.value_ptr.private = true; } } /// §7.3/§9.4 — open the S-reference deferral record on first use. Lazy so a /// session that never sees an S card allocates nothing. fn treeDeferrals( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, ) error{OutOfMemory}!*repostate.TreeDeferrals { if (self.tree_deferrals == null) self.tree_deferrals = repostate.TreeDeferrals.init(gpa, store); return &self.tree_deferrals.?; } /// §9.4 — the dephantomization path's traversal of the third edge kind. /// Called at every dephantomization point, beside `pending.completeFor` /// (which traverses the two older kinds), so "a cascade that walks only the /// two older edge kinds leaves such a check-in permanently unprocessed" is /// not what zit does. /// /// Traversing a recorded edge does two things: it retires the gap, so a /// dependent whose LAST gap closes leaves the deferred set (§5.2: a tree /// with any gap left is still not a file set), and it RE-RUNS §7.1 over the /// rediscovered dependent's own plaintext — the retry §7.3 asks for. The /// re-run is what makes the §7.1 "converges in at most d+1 pull rounds" /// claim hold for a dependent whose reference set could not be applied when /// it was stored. /// /// Total: `onArrival` removes the edge it reports, so each recorded gap /// drives exactly one retry and the loop cannot revisit one. fn noteSubManifestArrival( self: *Client, gpa: std.mem.Allocator, store: repostate.ArtifactStore, obs: *RoundObservation, arrived: []const u8, ) error{OutOfMemory}!void { if (self.tree_deferrals == null) return; // no S card seen this session: nothing recorded var retries: std.ArrayListUnmanaged(repostate.Rid) = .empty; defer retries.deinit(gpa); try self.tree_deferrals.?.onArrival(arrived, &retries); for (retries.items) |dep_rid| { // Owned copies: the retry re-enters `store.intern`, and only a // store's own contract says whether a borrowed name or plaintext // survives that. Two short-lived dupes per RECORDED GAP (not per // artifact) buy the traversal independence from that contract. const borrowed = store.content(dep_rid) orelse continue; const plain = try gpa.dupe(u8, borrowed); defer gpa.free(plain); const dep_name = try gpa.dupe(u8, store.hashOf(dep_rid)); defer gpa.free(dep_name); // Privacy: `false` never CLEARS a phantom's private marker (the // setter is one-way), so a retry cannot downgrade what §7.5 set. try self.absorbSubManifestRefs(gpa, store, obs, dep_name, plain, false); } } /// §7.7 L1437-1444 — this client's admission context, re-read on every ask. /// `shun` is a vtable whose answer MAY change between rounds (§3.3 /// L518-525), so this is deliberately a snapshot taken at the point of use /// and never a value cached at session init. fn targetAdmission(self: *const Client) TargetAdmission { return .{ .policy = self.hash_policy, .shun = self.shun, .clone = self.operation == .clone }; } 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.7 L1437-1444 — the mutation-side guard. Both card arms ask before // calling in, so today this only ever re-confirms; it is here because // EVERY path below mutates the store and the arms are not the only way in // (a future card path, and the deferred queue, both land bytes under // `hash`). `from_cfile` is `wire_container != null`: only §7.2's delta // form carries a §4.1 container, and §7.1 passes null, so the L1440 // clone-`file` bypass and the L1440-1444 clone-`cfile` discard stay // distinguishable this far down. Discarding is a silent return — §7.7 // asks for no error and no observation. // artifact-format §3.2 L347-350 — DELTASRC NAMES AN ARTIFACT, and this // receiver keys DURABLE STATE by it: the dangling arm below interns it as // a phantom and every later §3.3 scan asks the shun table and the pending // queue about that name, both of which are keyed by complete artifact // name. `parseFileCard`/`parseCfileCard` validate arity and the byte // counts but deliberately not the hash width (they take no hash-policy // dependency), so without this check the PEER chooses the key domain of // this client's own phantom set. `parseIgotCard`/`parseGimmeCard` already // reject exactly this shape as malformed; a delta card is simply the third // way a name enters the set, and it gets the same answer. HASH itself // needs no such gate — `admitInbound` answers an out-of-domain width // rather than asserting on it, and `store.put`/`intern` keep a name // opaque. if (artifact.nameAlgo(deltasrc.len) == null) return self.abort(); const admission = self.targetAdmission(); if (!admission.admits(hash, wire_container != null)) return; // §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; // submanifests §9.4 — an arrival retires the gaps recorded // against it, on the clone path too. try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid)); // §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), admission) 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); try self.absorbCluster(gpa, store, obs, reconstructed); // submanifests §7.1 — same rule on the reconstructed plaintext. try self.absorbSubManifestRefs(gpa, store, obs, store.hashOf(rid), reconstructed, was_private); try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid)); // §9.4 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), admission) 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 L1370-1372 — 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 L1132-1150 — 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 L1517-1518 — 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 L1537-1539). A server tuning knob, never wire-visible: the /// client resumes each batch from the `clone_seqno NEXT` cursor regardless /// of size (§8.2 L1505-1506 — clients MUST NOT infer count or size from range). clone_batch_max: usize = clone_cfile_batch_max, /// submanifests §8.1 — this repository's `enable-submanifests` setting: "a /// project-scope boolean, default false". It is the ONE input that arms the /// §7.2 peer-version gate on the send path and raises the version this /// server announces (`announcedVersion`). /// /// Off is not "gate disabled by accident": off means this repository does /// not participate in sub-manifests at all, which is the state §8.2 requires /// of every repository that is not a disposable test fixture, and the state /// in which zit's send path is byte-identical to what it was before this /// draft. On, the sender pays a plaintext scan per emitted artifact and /// refuses every S-bearing one to a peer below the threshold. An operator /// turning it on is shown `submanifest_enable_warning` first. submanifests_enabled: bool = submanifest_emission_default, /// submanifests §8.1 — this repository's durable seen-submanifest indicator /// ("When the first S-bearing artifact is stored, an implementation SHOULD /// record a local seen-submanifest indicator"), read from the repository /// config. The SECOND thing that arms the §7.2 gate, and the one that makes /// the gate honest: §8.1 says disabling the setting "cannot retire existing /// ones", so a repository can hold trees with the setting off — which is the /// state of any zit whose operator ran `zit submanifest disable` after /// committing, and of every zit that received a tree by sync without ever /// enabling emission. Arming on the setting alone would serve those trees /// ungated to a fossil peer, collapsing §8's transfer layer into its /// emission layer. /// /// Arming on this indicator costs nothing anywhere it matters: it is false /// for every repository predating this draft, and the persist boundary sets /// it for BOTH §7.2 shapes, so "indicator false" implies "holds no gated /// artifact" and the gate it skips would have decided `.emit` for every byte /// it looked at. seen_submanifest: bool = false, /// submanifests §6.3 — this repository's pinned `submanifest-k` (0 = /// unpinned), served over §9.3's scalar channel so a clone partitions /// identically. Read-only here: the transfer layer never consults k, only /// the §9.3 responder reports it. submanifest_k: u8 = 0, /// submanifests §6.4 — this repository's `submanifest-auto-retune` /// setting, served for the same project-scope reason and consulted by /// nothing else on this side. submanifest_auto_retune: bool = false, /// §3.3 L527-530 / §8.8 L1777-1779 — this repository's shun table, asked by /// the reply's phantom scan. §3.3 states the filter on "the ordinary phantom /// scan" without qualifying a side, and a server under push-receive IS a /// receiver (§3.3 r24(1) L511-514): it interns a client's `igot` for content /// it lacks and would otherwise `gimme` it, storing what the operator shunned. /// Borrowed for the reply's life and never written; `.none` leaves a server /// that names no set behaving exactly as it did before the filter existed. shun: ShunSet = .none, /// §7.7 L1428-1432 — this repository's effective hash-policy value, the /// SHA1-name half of `admitInbound`'s combined target predicate. Same /// per-session, read-per-card shape and same `.auto` default as /// `Client.hash_policy`; see there. hash_policy: artifact.HashPolicy = .auto, /// §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, /// §9.3 r31/r33 — the sub-manifest settings an AUTHORIZED pushed /// config card asked this repository to adopt; all-null on every other /// reply, including a rejected push. Reported rather than applied /// because applying is I/O and this layer is sans-IO: the CLI seam that /// owns the served repository's config performs the write. config_apply: SubmanifestConfig = .{}, }; /// §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, self, 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 }; // §9.3 r31 L2427-2429 — a pushed config card is Admin-gated, and the // gate is decided BEFORE the clone branch and before ingest so an // unauthorized push changes nothing at all: the rejection is the whole // reply (reset-and-emit, §5.2 L989-993), not an error card appended // beside a served round. Authorized rows are reported to the caller, // never applied here — this layer performs no I/O. var config_apply: SubmanifestConfig = .{}; if (scan.config_push) |payload| { switch (serverConfigPushDecision(self.hasAdmin(&scan))) { .reject => return .{ .body = try emitError(gpa, out, line_buf, .config_push_unauthorized), .media = .x_fossil, }, .apply => config_apply = parseSubmanifestConfigPayload(payload), } } // §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 L1489-1490 — a version-3 clone reply is served as // application/x-fossil-uncompressed; §4.1 L885-887 — no outer // container, because the cfile payloads are already stored compressed. return .{ .body = out.items, .media = .x_fossil_uncompressed, .config_apply = config_apply }; } try ingest(self, gpa, 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, .config_apply = config_apply }; } /// §9.3 r31 — the Admin predicate for a pushed config card. /// /// `Capability` deliberately models no admin letter ("admin has none"), so /// there is no capability string to test; the only authority this server /// establishes is a VERIFIED named login. Admin is therefore exactly "this /// server holds a credential AND this request carried a login card that /// reconstructed against it". Calling it before `authFault` has passed would /// read the second conjunct as verified when it is merely present, so this /// is called only from `respondWithMedia`, after the auth verdict. /// /// The consequence is deliberate and is the safe direction §9.3 r31 asks /// for: an anonymous server (`credential == null`, which is every `zit /// serve` today) grants Admin to nobody, so every pushed config card is /// rejected rather than applied by a stranger. fn hasAdmin(self: *const Server, scan: *const RequestScan) bool { return self.credential != null and scan.login != null; } /// submanifests §7.2 — the release integer this server announces, raised to /// the gate threshold because zit reads sub-manifests. The §7.2 gate reads /// the PEER's announcement, so a server that will serve trees must announce /// that it understands them. fn announcedVersionInfo(self: *const Server) VersionInfo { return .{ .version = announcedVersion(self.version.version, submanifest_reader_support), .datetime = self.version.datetime, }; } /// submanifests §7.2/§8.1 — is the send gate armed for this repository? /// /// Either the operator opted in (§8.1's setting) or the repository has /// already stored a §7.2-gated artifact (§8.1's seen-submanifest indicator). /// The disjunction is what keeps the gate from being a function of the /// EMISSION policy alone — §8.1's own "Disabling the setting ... cannot /// retire existing ones" is the state the second disjunct covers. fn submanifestGateArmed(self: *const Server) bool { return self.submanifests_enabled or self.seen_submanifest; } /// submanifests §7.2 — does this server advertise `avoid-submanifests`? /// "A server whose enable-submanifests setting (Section 8.1) is false sends /// the pragma avoid-submanifests, in the same reply positions as /// avoid-delta-manifests." /// /// The condition is the SETTING alone, not `submanifestGateArmed`, and this /// predicate exists so that difference is a name rather than a comment above /// an inline `if`. It sits beside `submanifestGateArmed` on purpose: two /// visibly separate questions, each asked once by the draft. The pragma /// tells a client "do not put S cards into commits you make against me", /// which a repository that already holds trees but has emission off still /// means — §8.1's "Disabling the setting stops the emission of new tree /// manifests; it cannot retire existing ones". Reading the armed disjunction /// here would silently stop advertising the refusal for exactly those /// repositories. /// /// Do not unify the two. The single state where they disagree — setting /// off, seen-submanifest on — is pinned in both directions by the test /// "the emitted pragma keys on the setting alone, not on the armed /// disjunction". fn advertisesAvoidSubmanifests(self: *const Server) bool { return !self.submanifests_enabled; } /// submanifests §7.2 — emit the pragma whenever /// `advertisesAvoidSubmanifests` says this server advertises it. /// /// Position: [FossilSync] fixes no ordinal for avoid-delta-manifests beyond /// "the reply" (§B.7 gives only its production; §9 the receipt semantics), /// and §8.2 L1519-1520 fixes only that server-version precedes the clone /// sequence. So both replies emit it in the leading pragma run, immediately /// after server-version where that is emitted — one position, stated once, /// for the normal and the clone reply alike. /// /// zit's own default is false, so this pragma is on every reply a /// stock-configured zit server sends. That is what §7.2 specifies, and it is /// safe against a pre-S peer by [FossilSync] §3.5's tolerate-and-ignore rule /// for unrecognised cards — the `zig build smoke` battery against fossil /// 2.28 is the evidence, not the assumption. fn appendAvoidSubmanifests( self: *const Server, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, ) RespondError!void { if (!self.advertisesAvoidSubmanifests()) return; const p = writeFlagPragma(.@"avoid-submanifests", line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, p); } /// §9.3/§9.3 r21-r26 — answer a `reqconfig /config` with this /// repository's three project-scope sub-manifest settings. /// /// Read permission is the SOLE gate for reading (r21); Admin is required /// only for the push-and-apply direction (r31). zit's server performs no /// per-user capability lookup and grants Read to every requester (§A.1 /// note 1), so the disposition is stated through `serverReqconfigDisposition` /// rather than assumed — the one place a future capability model has to /// change. /// /// Emitted only when the request actually named the scalar group, so a peer /// that never asks — every peer predating this feature — gets a reply /// byte-for-byte identical to what it got before. fn appendScalarConfig( self: *const Server, gpa: std.mem.Allocator, scan: *const RequestScan, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, ) RespondError!void { if (!scan.reqconfig_scalar) return; if (serverReqconfigDisposition(server_grants_read) == .ignore) return; var payload_buf: [submanifest_config_payload_max]u8 = undefined; const payload = writeSubmanifestConfigPayload( self.submanifests_enabled, self.submanifest_k, self.submanifest_auto_retune, &payload_buf, ) catch return error.ResponseTooLarge; const card = writeConfigCard(config_scalar_marker, @intCast(payload.len), line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, card); // §9.3 r23/r29 — SIZE content bytes then the one mandatory trailing LF, // which `appendCard` writes for us. try appendCard(gpa, out, payload); } /// submanifests §7.2 — the send decision for one outbound artifact, given /// its plaintext. /// /// Two guards, in cost order. §8.1's arming indicator comes first and is the /// reason an existing repository pays nothing: with neither the setting nor /// the seen-submanifest indicator set this returns `.emit` without touching /// `bytes`, so the send path is byte-for-byte and instruction-for-instruction /// what it was before this draft. Only a repository that opted in or already /// holds a gated artifact pays the card scan. /// /// The peer's version is its announced `pragma client-version` (§10.1); a /// peer that announced none is `Peer.serverInit()`'s 0, which is below every /// threshold, so an unannounced peer is treated as pre-S. That is the safe /// direction and matches §10.1 r10's own pre-pragma default. fn submanifestSendGate( self: *const Server, scan: *const RequestScan, bytes: []const u8, ) GateDecision { if (!self.submanifestGateArmed()) return .emit; // The same two-shape disjunction the client half reads: §7.2 gates a // SUBMANIFEST artifact by TYPE as well as an S-bearing manifest, and a // leaf sub-manifest is only ever the former. return submanifestPeerVersionGate(peerVersionOf(scan), repostate.transferGatedArtifact(bytes)); } /// §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.announcedVersionInfo(), line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, p); } } try self.appendAvoidSubmanifests(gpa, out, line_buf); try self.appendScalarConfig(gpa, scan, out, line_buf); // §3.3 L532-533 — satisfy each gimme the server holds with a file card, // in request order; a hash it lacks is silently skipped (§3.3 L535). // // submanifests §7.2 — the send gate lives here, on the ONE path that // already holds the artifact's plaintext ("uploading composes from // plaintext"), so an armed gate costs one card-run walk per emitted // artifact and never a second inflate. Refusing per artifact // rather than per reply is what §7.2 asks for: the reply still carries // every artifact the peer CAN read, and the error card says exactly what // was withheld. var s_refused = false; for (scan.gimmes.items) |hash| { if (store.lookup(hash)) |rid| { if (store.content(rid)) |bytes| { if (self.submanifestSendGate(scan, bytes) == .refuse) { s_refused = true; continue; // §7.2: refused, never converted or downgraded } writeFile(gpa, out, store.hashOf(rid), bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoSpaceLeft => return error.ResponseTooLarge, }; } } } // §7.2 "with an explicit error" — one error card for the round, at // §11.1 r8's APPEND disposition (`serverErrorReset(.peer_version_gate)` // is `.append`), which is the disposition the parallel SHA3-256 gate of // [FossilSync] §7.6 carries and the reason the reply's other artifacts // survive alongside it. if (s_refused) { std.debug.assert(serverErrorReset(.peer_version_gate) == .append); const card = writeSignalCard(.err, submanifest_peer_version_error_text, line_buf); try appendCard(gpa, out, card); } // §3.2 L399-403/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). // // The gate is `held()`, NOT `content()`: this walk visits every rid, and // `content()` on a wire-backed entry INFLATES it, so reading the held set // through it made advertising the store cost the store's whole // uncompressed size — on every round, which is what a large repository // could not answer inside a connection deadline. `held()` is the §3.3 r24 // held-set predicate and answers from the retained form alone. The two // disagree only for a W4 container that does not inflate; that artifact // IS held (it is retransmitted verbatim and persisted), so advertising it // with igot rather than begging for it with gimme is also the more // accurate statement. The SEND gate above still reads `content()`, // because uploading composes from plaintext. const n: repostate.Rid = store.count(); var rid: repostate.Rid = 1; while (rid <= n) : (rid += 1) { const hash = store.hashOf(rid); if (store.held(rid)) { 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 if (!self.shun.contains(hash)) { // §3.3 L527-530 — the ordinary phantom scan excludes names in // the repository shun table. The phantom stays (the client's // igot still minted it, §3.3 r24(1) L511-514); the reply simply // never begs for it, so a shunned artifact the peer holds is // never pulled in through the serve direction either. 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 L1504); 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 L1519-1520 — 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.announcedVersionInfo(), line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, p); } } try self.appendAvoidSubmanifests(gpa, out, line_buf); // §8.2 L1517-1518 — 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. // // submanifests §7.2 — the clone half of the send gate. A clone serves // stored containers VERBATIM and never inflates one, which is the // O(wire) property this reply is built around; deciding S-bearingness // needs plaintext, so the decision is fenced twice before any inflate // can happen. Both fences are false for every repository that predates // this draft, so the clone path here is instruction-identical to what it // was: neither §8.1 arming disjunct holds, and even when one does the // gate is inert for a peer at or above the threshold. Only a repository // that holds trees being cloned to a pre-S peer pays an inflate, and // pays it to REFUSE, which is the outcome §7.2 demands over a silent // omission. const clone_gate_armed = self.submanifestGateArmed() and submanifestPeerVersionGate(peerVersionOf(scan), true) == .refuse; var s_refused = false; 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 (clone_gate_armed) { // The inflate happens ONLY inside this branch, which the two // fences above keep unreachable for every unarmed server. if (store.content(r)) |plain| { if (repostate.transferGatedArtifact(plain)) { s_refused = true; continue; // §7.2: refused, never converted or downgraded } } } 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 L1214-1216). // NO CONTENT-RECONSTRUCTING ASSERTS HERE. This arm used to // reconstruct every served artifact AND its delta basis // under `runtime_safety`, purely to assert that the declared // size matched and that the basis was present at an earlier // rid. That made the SHIPPED build (ReleaseSafe enables // runtime_safety) do several times the I/O of `--release=fast` // on the hot clone path, and it was not free in the way an // assert is supposed to be: it was O(store) work inside the // server's per-request answer. // // It caused a real outage rather than catching one. The // per-connection deadline in `serve.zig` bounds read plus // answer plus write, so once these reconstructions pushed a // clone round past it, the deadline cancelled the connection // mid-answer, the in-flight pread failed, and the peer got an // HTTP 500 on a perfectly healthy store. // // The properties are still checked, in the place that can // afford them: `zit verify` reconstructs every artifact // through its delta chain and re-hashes it against its own // name, which subsumes the size assert entirely. An fsck run // on demand is the right home for O(store) verification; a // clone round is not. 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. // The assert that reconstructed the artifact to prove that // prefix equalled the uncompressed length is gone for the // reason given in the delta arm above: it is O(store) work on // the server's answer path, and `zit verify` proves the same // property on demand. 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 L1520 — 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 L1519, §8.5 L1631-1632 — `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); // submanifests §7.2 "with an explicit error" — appended AFTER the // sequence cards so the batch the peer CAN read still lands and the // §8.2 cursor still advances; §11.1 r8 puts the peer-version gate at the // append disposition precisely so a refusal does not discard the reply. if (s_refused) { std.debug.assert(serverErrorReset(.peer_version_gate) == .append); const card = writeSignalCard(.err, submanifest_peer_version_error_text, line_buf); try appendCard(gpa, out, card); } } }; /// §8.2 L1517-1518 — 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 L1537-1538), 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 L1493-1500 — `clone VERSION SEQNO` with VERSION≥2 and digit-only SEQNO≤0 config_push_unauthorized, // §9.3 r31 L2427-2429 — a pushed config card without Admin }; /// §6.4 — the impl-chosen auth-failure TEXT. The RFC pins the reset-and-emit /// behaviour (§6.1 L1078-1079, §6.4 L1152-1153) 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) /// §9.3 r20/r24 — this request asked for the SCALAR settings group /// (`reqconfig /config`). A table group name leaves it false: its group mask /// is zero here, which §9.3 makes a silent no-op, not an error. reqconfig_scalar: bool = false, /// §9.3 r23/r29 — the framed payload of a pushed scalar `config /config` /// card, borrowing the request bytes. Recorded, never applied, during the /// scan: §9.3 r31's Admin gate is decided after the login verdict, and a /// rejected push must leave the request wholly without effect. config_push: ?[]const u8 = null, /// §7.7 L1447-1450 — DELTASRC names of pushed delta-form `file` cards whose /// basis this repository could not resolve: "if the content is unavailable, /// ordinary dangling-delta handling creates or retains the source phantom, /// but the Section 3.3 scan excludes it, so it is not requested." Kept /// separate from `igots` because these names were never ANNOUNCED — a /// dangling source must still be gimme-eligible when it is not shunned, /// which `emitReply`'s `containsHash(scan.igots.items, …)` suppression would /// silently defeat if the two lists were merged. Borrows the request bytes. delta_phantoms: std.ArrayListUnmanaged([]const u8) = .empty, fn deinit(self: *RequestScan, gpa: std.mem.Allocator) void { self.igots.deinit(gpa); self.gimmes.deinit(gpa); self.delta_phantoms.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. /// /// `server` is read for the §7.7 L1434-1437 admission predicate only: the pushed /// `file` arm must answer it BEFORE it reconstructs, because a shunned target /// "emits no error" (L1437-1440) and reconstruction is the one step of this scan /// that can raise one. fn scanRequest( gpa: std.mem.Allocator, server: *const Server, 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, server, 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, server: *const Server, 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 } }; // §7.7 L1437-1440 — "A non-clone file whose target is shunned is // consumed and discarded after its payload and pending private // marker; it emits no error, does not set content-received, and makes // no repository mutation." Server push-receive is always non-clone // (§3.9's request vocabulary carries no clone-side content delivery), // so the discard arm applies unconditionally and no `operation` guard // belongs here. // // The gate sits in the SCAN, above the reconstruction, and not merely // on the pass-2 mutation: "emits no error" is unconditional across // both forms, and `reconstructPushedDelta` below is the one step of // this scan that can raise a fault. A basis this repository does not // hold is the COMMON case for a shunned target — precisely because it // is shunned, its neighbours are unlikely to be here — so gating only // the `put` would answer a required-silent discard with // `error malformed file line`. `ingest` keeps the same test as a // belt-and-braces mutation guard; this one keeps the reply clean. // // Discarding is an early `.advance` past the framed payload, so the // card contributes no `scan.files` entry, no `delta_phantoms` entry // (admission is decided before DELTASRC is ever looked at), and the // cursor still resumes exactly where the framing says (§5.1). if (admitInbound(server.hash_policy, server.shun, fc.hash) == .reject) return .{ .advance = payload.next }; 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 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, // §7.7 L1444-1450 — "For an admitted target whose DELTASRC // NAME IS IN THE REPOSITORY SHUN TABLE, the receiver does not // apply admission independently to that source: content still // held locally MAY serve as the delta basis; if the content is // unavailable, ordinary dangling-delta handling creates or // retains the source phantom, but the Section 3.3 scan // excludes it, so it is not requested." // // The clause is SCOPED by its own opening: it governs a // DELTASRC in the shun table, and nothing else. In the served // store a shunned name is masked out of the by-name index, so // that is precisely how the clause arrives here — but so does // every OTHER cause of an absent basis (an unknown name, a // truncated one, an attacker-chosen one), and for those §7.7 // says nothing. Widening the clause to cover them would delete // the §7.4/§3.8 fault this receiver has always raised for a // delta it cannot reconstruct, so the arm asks the clause's own // question first and only then takes the non-error path. // // The predicate is the SHUN TABLE alone (L1445), not // `admitInbound`'s combined one: applying admission to DELTASRC // is exactly what L1434-1435 forbids. The width test is not // there to protect `ShunSet` — that wrapper answers an // out-of-domain width rather than trapping — but to say what // "absent basis" MEANS: `parseFileCard` validates arity and // SIZE and not hash width, so a token of any other width names // no artifact this repository could hold or shun, and it takes // the ordinary §7.4 fault rather than the L1445 quiet path. // // What zit does NOT do, stated plainly rather than papered // over: §3.3 L537-539's dangling-delta handling is a // conjunction — "retain the received delta under HASH, delete // HASH's phantom, create DELTASRC's phantom, and set // content-received" — and only the third conjunct lands here. // `repostate.ArtifactStore` exposes no entry point that stores // a delta under a name whose basis is absent (`put` takes // plaintext), and the server is stateless (§3.8) with no // cross-request `PendingDeltaQueue`, so the delta bytes are // dropped. The consequence is real and not hidden: the target // does not become held and no gimme recovers it. It is bounded // to the shunned-source case, where retention would buy // nothing — the basis is shunned, so the "recursive basis // availability" fixpoint §3.3 L540-541 relies on can never // reach it — and §7.7 L1461-1463 accepts exactly this dead end // ("Section 3.7 permits either unresolved phantom to remain // when the session ends. This is source-faithful behavior, not // a recovery path."). A store able to park a basis-less delta // is the fix; it is not a §7.7 question. error.BasisAbsent => { const src_in_shun_table = artifact.nameAlgo(deltasrc.len) != null and server.shun.contains(deltasrc); if (!src_in_shun_table) return .{ .fault = .{ .malformed = .file } }; try scan.delta_phantoms.append(gpa, deltasrc); return .{ .advance = payload.next }; // §5.1 — resume past the payload }, 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/§9.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`. // // §9.3 r24 — the SCALAR marker is the one group zit models: it // carries the three project-scope sub-manifest settings // (submanifests §6.3/§8.1 and §6.4). Every TABLE group // (r28) stays out of scope, so its mask is zero and the server // emits NO config cards and NO error (§9.3 L1884-1886) — which is // also what keeps a Fossil clone's `reqconfig /all` completing // past the §8.4 configuration phase instead of drawing an // `error bad command`. (The project code does NOT travel here: it // rides the clone reply's `push` card, §8.2/§8.3, and 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 } }; if (configKind(name) == .scalar) scan.reqconfig_scalar = true; }, .config => { // §9.3 r23/r29/r32 — a pushed configuration card: `config // GROUPNAME SIZE`, SIZE content bytes, one mandatory trailing LF. // Frame by the byte count, never by scanning. A malformed header, a // negative SIZE (r32's own fatal action is to abort the // transaction — which reset-and-emit is), or a frame running past // the request is `error malformed config line`, and the scan stops // before pass-2 ingest, so a bad card stores nothing. const header = parseConfigCard(cc.tokens) catch return .{ .fault = .{ .malformed = .config } }; const p = consumeConfigPayload(request, line.next, header.size) catch return .{ .fault = .{ .malformed = .config } }; // §9.3 r34 — only the scalar form is pushed; a table group is // framed and ignored rather than rejected, so a peer that pushes // one still gets a served round. if (configKind(header.groupname) == .scalar) scan.config_push = p.bytes; return .{ .advance = p.next }; // §5.1 — resume past the payload }, // §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 L1078-1079) /// or a signature that fails to reconstruct (§6.4 L1152-1153) 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; } /// §10.1 r10 L2317-2319 — the peer version a reply-side gate reads: the /// client-version pragma this request carried, or the server's pre-pragma /// default of 0 when it carried none. Stated once so the §7.2 gate's two /// reply paths (`emitReply`, `emitCloneReply`) cannot disagree about what an /// unannounced peer is; 0 is below every threshold, which is the safe /// direction and the default §10.1 r10 already fixes. fn peerVersionOf(scan: *const RequestScan) u32 { return if (scan.client_version) |cv| cv.version else 0; } /// §3.3 — apply the request's store effects: store each pushed file (§3.3 L537, /// 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 L511-517/§3.2 L399-403). /// /// §7.7 L1437-1440 — this is the SERVER's receive-side admission point. A push /// request's `file` cards are the only inbound content a server takes, and /// server push-receive is always non-clone (§3.9's request vocabulary carries no /// `cfile` and no clone-side content delivery), so the non-clone discard arm /// applies unconditionally: no `operation` guard is asked for or written here. /// /// The DECIDING gate is in `scanCard`'s `file` arm, above the delta /// reconstruction, because "it emits no error" is unconditional and /// reconstruction is the only step that can raise one; a shunned card therefore /// never reaches `scan.files` at all. The test repeated below is the /// belt-and-braces one on the MUTATION itself: `put` is what §7.7 forbids, and /// a future scan path that collects a file card without asking must still not /// land it. /// /// SO IT IS DELIBERATELY UNTESTABLE, and recorded as such rather than left to /// read as covered. `server` is const across scan and ingest within one /// `respond`, and `scanCard` asks the identical predicate over it before /// appending, so no input `scanRequest` can produce reaches this line with /// `.reject`. No mutant of it can be killed by the suite; the §7.7 server tests /// exercise the scan gate. That is the price of a guard whose value is against a /// scan path that does not exist yet, and it is the right trade here — the /// failure it guards is landing content under a name a takedown order names. /// Nothing else about the request changes — no error card is emitted, /// and the round proceeds to `emitReply` as if the card had not carried content /// this repository refuses to hold. /// /// §3.3 r24(1) L511-514 — the `igot` intern is deliberately NOT filtered: /// phantom creation is unconditional on the shun table. §3.3 L527-530 excludes /// the shunned phantom at the SCAN, so the reply never begs for it. /// /// submanifests §7.1 — "Storing a structural artifact that carries S cards whose /// referenced sub-manifests are absent MUST create a phantom for each missing /// reference." The rule is about STORING and is side-agnostic, so a pushed tree /// manifest whose sub-manifests the pusher does not hold phantomizes them here, /// on the server, exactly as the client's receive path does for a pulled one. /// Because the phantom lands in the same store the §3.2 walk below reads, the /// server's own reply then gimmes the missing sub-manifests in the SAME round — /// which is the whole point: without it, a push leaves the server holding a /// check-in it can never enumerate and never asks anyone for the rest. /// /// The two guards compose in one direction only: admission is asked FIRST, so a /// card §7.7 refuses is never stored and mints no sub-manifest phantoms either. /// That is the wanted reading — the S references of an artifact this repository /// declines to hold are not names it should then go begging for. /// /// `gpa` buys only the extractor's scratch; the scan is fenced by /// `sBearingCandidate`, so a push of artifacts predating this draft costs one /// memchr per artifact and allocates nothing. fn ingest( server: *const Server, gpa: std.mem.Allocator, store: repostate.ArtifactStore, scan: *const RequestScan, ) Server.RespondError!void { for (scan.files.items) |f| { if (admitInbound(server.hash_policy, server.shun, f.hash) == .reject) continue; _ = try store.put(f.hash, f.payload); var refs: std.ArrayListUnmanaged([]const u8) = .empty; defer refs.deinit(gpa); // Validate-then-apply inside the extractor: a malformed card run mints // nothing rather than a half-minted phantom set. if (!try repostate.collectSubManifestRefs(gpa, f.payload, &refs)) continue; for (refs.items) |hash| _ = try store.intern(hash); } for (scan.igots.items) |hash| _ = try store.intern(hash); // §7.7 L1447-1450 — "ordinary dangling-delta handling creates or retains the // source phantom, but the Section 3.3 scan excludes it, so it is not // requested." Same `intern` the announced set uses (§3.3 r24(1) L511-514): // idempotent for a name already held or already phantom. Unfiltered, exactly // like the igot intern above — admission is a TARGET-only question // (L1434-1435), so a shunned source still gets its phantom; §3.3 L527-530 at // `emitReply` is what stops the reply begging for it. for (scan.delta_phantoms.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 L969-970 / §6.1 L1078-1079 — 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 L1495-1498 — 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), // §9.3 r31 L2427-2429 — the RFC pins this TEXT exactly, unlike the // impl-chosen `respond_unauthorized_text` above; it is stated once, in // `config_push_unauthorized_text`, and emitted verbatim here. .config_push_unauthorized => writeSignalCard(.err, config_push_unauthorized_text, line_buf), }; try appendCard(gpa, out, card); return out.items; } /// §8.1 L1495-1498 — 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 // --------------------------------------------------------------------------- /// A zit resource limit, NOT a protocol rule: the sync draft imposes no /// maximum line length (§5.2 L962; §11.3 L2199). `readLine` will not scan for /// LF past this window, so a longer line -- which a conforming peer may legally /// send -- fails as `MissingTerminator` here. Measured from the first line byte /// through and including the terminating LF, excluding any following /// byte-counted payload. /// /// The citation this carried (`§5.2 -- card/comment line bound`) named a bound /// that section explicitly disclaims. Issue #32 classified this constant as a /// principled tripwire on the strength of that citation; the justification is /// void, and it survives as documented local policy rather than as a spec /// requirement. 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", /// draft-fossil-submanifests-00 §7.2 amends this catalog: "A server whose /// enable-submanifests setting (Section 8.1) is false sends the pragma /// avoid-submanifests, in the same reply positions as /// avoid-delta-manifests." Same direction, same arg-less shape; listed /// immediately after the pragma it is named for. @"avoid-submanifests", }; comptime { // §10 L2287 — "Fourteen pragma names are defined." — plus the one // draft-fossil-submanifests-00 §7.2 adds. std.debug.assert(@typeInfo(PragmaName).@"enum".fields.len == 14 + 1); } /// §9 L1802-1806 — 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", // submanifests §7.2: sent by a SERVER whose enable-submanifests setting // is false, "in the same reply positions as avoid-delta-manifests". .@"avoid-submanifests", => .server_to_client, }; } comptime { // §10 L2287-2291 — "Nine are client-to-server ... Five are // server-to-client." submanifests §7.2 adds one more server-to-client name. 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 + 1); } /// §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); } /// §9 L1799-1802 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, §9.3 r22) rather than each re-deriving the predicate. /// §8.3 L1598-1599 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, §8.5.1 L1641-1642 — 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, §8.5.1 L1658-1662 — 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; } /// §8.5.1 L1718-1719, §9.2 L1872-1874 — 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 L302-311 — 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, }; /// §8.5.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.2 L1872-1874, §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; } /// §9.3 L1893-1894 — 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"; /// §9.3 L1893-1917 — a config card's payload is either the fixed /// scalar marker or a table GROUPNAME (r24 vs. r28). pub const ConfigKind = enum { scalar, table }; /// §9.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; } /// §9.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] == '/'; } /// §9.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}); } /// §9.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; } /// §9.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 }; /// §9.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; } /// §9.3 r21 / §A.1 note 1 — whether THIS server holds Read for a requester. /// /// zit's server performs no per-user capability lookup: `Capability` models the /// letters the RFC defines and the server accepts unauthenticated pull/clone, /// which is Read granted to everyone. Named rather than inlined as `true` so /// the single place a capability model would attach is visible, and so the /// read gate reads as a gate at the call site instead of as a constant. const server_grants_read: bool = true; /// §9.3 r23 L2377-2378 — a parsed `config GROUPNAME SIZE` card header. pub const ConfigCard = struct { groupname: []const u8, size: u32 }; /// §9.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 }; /// §9.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 }; } /// §9.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 }); } /// §9.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 }; /// §9.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 }; /// §9.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; } /// §9.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; } /// §9.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}; /// §9.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; } /// §9.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 }; /// §9.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; } /// §9.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; } /// §9.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; } /// §9.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"; /// §9.3 r31/r33 — the server's disposition of a pushed config card. pub const ConfigPushDecision = enum { apply, reject }; /// §9.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; } // --- §9.3 scalar settings: the sub-manifest partition configuration -------- // // draft-fossil-submanifests-00 §6.3 requires PROJECT scope of the partition // parameter ("configuration propagating to clones ... is REQUIRED"), §8.1 the // same of `enable-submanifests`, and §6.4 adds `submanifest-auto-retune` as a // third project-scope boolean. [FossilSync] §9.3's scalar channel is the only // transport the RFCs give for a repository setting, so these three ride it. // // The names below ARE the wire contract: a peer matches on them literally, so // they are stated once here and never re-spelled at a call site. /// submanifests §8.1 — the emission opt-in, as named on the §9.3 wire. pub const submanifest_setting_enable: []const u8 = "enable-submanifests"; /// submanifests §6.3 — the pinned partition parameter, as named on the wire. pub const submanifest_setting_k: []const u8 = "submanifest-k"; /// submanifests §6.4 — the opt-in automatic re-tune switch, as named on the /// wire. pub const submanifest_setting_auto_retune: []const u8 = "submanifest-auto-retune"; /// §9.3 r25 — the MTIME this implementation stamps on the three rows it /// emits. /// /// zit's config frame carries no per-field write time (`logstore.encodeConfig` /// appends the three settings as raw tail bytes, with no timestamp anywhere in /// the frame), so there is no honest clock to report. 0 says exactly that: /// untracked. It is also the conservative value — §9.3's channel is /// last-write-wins on MTIME, so a 0-stamped row loses every comparison against /// a peer that does track write times, and this implementation can therefore /// never overwrite a better-informed peer's setting by accident. Clone-time /// adoption, the only direction this implementation applies (a fresh repository /// has nothing of its own to lose), does not consult MTIME at all. pub const submanifest_config_mtime: u64 = 0; /// §9.3 — the three settings as they arrive from, or depart for, a peer. Every /// field is optional and independently absent: a row this implementation could /// not read, or a peer that never sent one, leaves its field null rather than /// substituting a default, so "the peer said off" and "the peer said nothing" /// stay distinguishable at the adoption seam. pub const SubmanifestConfig = struct { enable: ?bool = null, k: ?u8 = null, auto_retune: ?bool = null, /// True when at least one setting was actually named. The adoption /// wrapper's whole gate: all-null means "peer sent none; keep local". pub fn any(self: SubmanifestConfig) bool { return self.enable != null or self.k != null or self.auto_retune != null; } }; /// §9.3 r25/r26 — the boolean rendering these rows carry. `on`/`off` matches /// what `zit submanifest` shows an operator and what §9.3's own example rows /// use for a Fossil boolean setting. fn submanifestBoolText(v: bool) []const u8 { return if (v) "on" else "off"; } /// §3.5 tolerate-and-ignore, at value granularity — read a boolean setting /// value, or null when the bytes are not one this implementation understands. /// `on`/`off` is what zit writes; `1`/`0` is what Fossil's own config table /// stores a boolean as, so both are accepted on the read side. Anything else is /// skipped by the caller rather than coerced. fn parseSubmanifestBool(raw: []const u8) ?bool { if (std.mem.eql(u8, raw, "on") or std.mem.eql(u8, raw, "1")) return true; if (std.mem.eql(u8, raw, "off") or std.mem.eql(u8, raw, "0")) return false; return null; } /// The widest quoted-name/value scratch one row may need. Names are the three /// literals above; values are `on`/`off` or a decimal `u8`. Both bounds are /// deliberately generous — a peer may send any name at all, and a row wider /// than the scratch is skipped, never truncated into a different setting. const submanifest_row_name_max: usize = 64; const submanifest_row_value_max: usize = 32; /// The byte ceiling of the three-row payload `writeSubmanifestConfigPayload` /// produces, derived from the row grammar rather than guessed: per row a /// worst-case decimal MTIME, one space, the quoted name (every byte doubled by /// `fossilQuote` in the worst case), the ` value ` literal, and the quoted /// value; plus the two LF separators between the three rows. pub const submanifest_config_payload_max: usize = blk: { const mtime_max = 20; // u64 decimal ceiling const name_max = 2 + 2 * submanifest_setting_auto_retune.len; // the widest of the three const value_max = 2 + 2 * "off".len; // wider than "on" and than any u8 decimal const row = mtime_max + 1 + name_max + " value ".len + value_max; break :blk 3 * row + 2; }; /// §9.3 r25 — assemble this repository's three settings as LF-separated scalar /// rows. No trailing LF INSIDE the payload: §9.3 r29's single mandatory /// newline follows the SIZE bytes and is the frame's, not a row separator, so /// splitting the received payload on LF yields exactly the rows written here. pub fn writeSubmanifestConfigPayload( enable: bool, k: u8, auto_retune: bool, out: []u8, ) std.fmt.BufPrintError![]u8 { // The direct `out[i]` writes below are safe only because the buffer is at // least the payload ceiling. This was an assert; the return type already // carries the right answer, so refuse instead of panicking a ReleaseSafe // caller that sized its buffer wrong. if (out.len < submanifest_config_payload_max) return error.NoSpaceLeft; var k_buf: [4]u8 = undefined; // u8 decimal ceiling is 3 digits const k_text = try std.fmt.bufPrint(&k_buf, "{d}", .{k}); var i: usize = 0; const rows = [3]struct { name: []const u8, value: []const u8 }{ .{ .name = submanifest_setting_enable, .value = submanifestBoolText(enable) }, .{ .name = submanifest_setting_k, .value = k_text }, .{ .name = submanifest_setting_auto_retune, .value = submanifestBoolText(auto_retune) }, }; for (rows, 0..) |row, n| { if (n > 0) { out[i] = '\n'; i += 1; } const written = try writeScalarConfig(submanifest_config_mtime, row.name, row.value, out[i..]); i += written.len; } const result = out[0..i]; std.debug.assert(result.len <= submanifest_config_payload_max); return result; } /// §9.3/§3.5 — read whatever of the three settings a scalar `/config` payload /// names, tolerating everything else. /// /// Row-granular tolerance is the whole point: a peer's scalar payload legally /// carries every setting IT models, most of which zit does not, and an older or /// newer peer may spell a value in a shape this implementation cannot read. A /// row that fails to parse, unquote, or convert is SKIPPED — the rows around it /// still apply and nothing is raised — so a peer that predates these settings, /// or postdates them, degrades to "sent none" rather than to a failed exchange. /// Allocation-free: each row unquotes through bounded stack scratch, and a row /// too wide for it is one more skip. pub fn parseSubmanifestConfigPayload(payload: []const u8) SubmanifestConfig { var cfg: SubmanifestConfig = .{}; var rows = std.mem.splitScalar(u8, payload, '\n'); while (rows.next()) |row| { if (row.len == 0) continue; const sc = parseScalarConfig(row) catch continue; var name_buf: [submanifest_row_name_max]u8 = undefined; if (sc.quoted_name.len - 2 > name_buf.len) continue; const name = fossilUnquote(sc.quoted_name, &name_buf) catch continue; var value_buf: [submanifest_row_value_max]u8 = undefined; if (sc.quoted_value.len - 2 > value_buf.len) continue; const value = fossilUnquote(sc.quoted_value, &value_buf) catch continue; if (std.mem.eql(u8, name, submanifest_setting_enable)) { if (parseSubmanifestBool(value)) |b| cfg.enable = b; } else if (std.mem.eql(u8, name, submanifest_setting_k)) { // §6.3's range rule is NOT applied here: 0 is the real "unpinned" // value, and an out-of-range pin is a state §6.3 already defines a // fallback for (`submanifest.emission` reports it to the operator). // Filtering it out here would silently substitute a parameter of // this implementation's own, which §6.3 forbids. cfg.k = std.fmt.parseInt(u8, value, 10) catch continue; } else if (std.mem.eql(u8, name, submanifest_setting_auto_retune)) { if (parseSubmanifestBool(value)) |b| cfg.auto_retune = b; } } return cfg; } // --------------------------------------------------------------------------- // Section 10.4 — Commit-Lock Pragmas // --------------------------------------------------------------------------- /// §9.4 L1939-1941 — `pragma ci-lock HASH CLIENT-ID` arguments. pub const CiLock = struct { hash: []const u8, client_id: []const u8 }; /// §9.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] }; } /// §9.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 }); } /// §9.4 L1946-1953 — 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 }; /// §9.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; } /// §9.4 L1965-1967 — `pragma ci-lock-fail LOGIN MTIME` arguments. pub const CiLockFail = struct { login: []const u8, mtime: u64 }; /// §9.4 L1965-1970 — 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 }; } /// §9.4 L1965-1967 — 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 }); } /// §9.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); // §9.4 L2548: no recorded fault -> commit proceeds std.debug.assert(!override_requested or result); // §9.4 L2551-2553: an override always permits return result; } /// §9.4 L1972-1973 — 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; } /// §9.4 L1972-1973 — 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}); } /// §9.4 L1976-1979 — 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 §9.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", // submanifests §7.2 states no argument for avoid-submanifests and // places it "in the same reply positions as avoid-delta-manifests", // whose production is `"pragma" SP NAME LF`. .@"avoid-submanifests", => 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}); } /// §9.5 L1983-1984 — the exact unauthorized-send-private error text. pub const send_private_unauthorized_text = "not authorized to sync private content"; /// §9.5 L1983-1985 — the server's disposition of a `send-private` /// pragma. pub const SendPrivateDecision = enum { enable, reject }; /// §9.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 L1376-1377 / §9.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; } /// §9.5 r43/r48 — the two per-pragma send schedules the RFC states in /// §9.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 }; /// §9.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, }; } /// §9.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]; } /// §9.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 L944-946 (usage sites: §11.1 r1/r2, §11.2 r1/r2, §3.6 L567-570) /// — 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, }; } /// §10.1 L2093-2097 — 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, }; /// §10.1 L2093-2097 — 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 L567, 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 L567-568 — 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 L567-568 — 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 L567-568, §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 L567-570/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 L1387-1389, §2 L249-250 — "The value 20000 corresponds to Fossil /// release 2.0." pub const sha3_min_peer_version: u32 = 20000; /// §7.6 L1387-1394, §11.4 L2205-2206 — exactly two outcomes. No third /// "downgrade" outcome exists: §11.4 L2205-2206 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 L1387-1389 — 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 §9.1 L1853-1862 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 (§11.4 L2205-2206): 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. // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 §7.2 / §8.1 — Sub-Manifest Transfer and Emission // --------------------------------------------------------------------------- /// submanifests §7.2 — the announced peer version at or above which a peer is /// known to support sub-manifests. The draft deliberately leaves the number to /// the implementation: "a threshold to be fixed at the first release /// implementing this specification". zit fixes it here, and the choice is /// constrained on both sides. /// /// It MUST be strictly above every released Fossil, because no released Fossil /// has any sub-manifest support: a threshold at or below 2.28 would let the /// gate pass an S-bearing artifact to a peer that would reject it as /// structural, which is exactly the silent-divergence §8 exists to prevent. /// 2.29.0 is the first version integer above the newest release. /// /// It is NOT the version zit announces by default. `announcedVersion` raises /// the announcement to this value only when the operator has enabled emission /// (§8.1); with emission off — the default — zit announces exactly what it /// announced before this draft existed, so no existing peer, Fossil included, /// observes any change. pub const submanifest_min_peer_version: u32 = 22900; comptime { std.debug.assert(submanifest_min_peer_version == encodeVersion(2, 29, 0)); // Above the newest released Fossil (2.28), which has no sub-manifest // support: see the constant's doc for why this direction is the safe one. std.debug.assert(submanifest_min_peer_version > encodeVersion(2, 28, 0)); } /// submanifests §7.2 — the explicit error a refused send carries. The draft /// fixes the RULE ("with an explicit error -- never by converting, downgrading, /// or silently omitting it") but no literal, and names its model: "The rule /// parallels the SHA3-256 peer-version gate of [FossilSync], Section 7.6." /// This text is therefore CONSTRUCTED on that parallel — the §7.6 form with /// this gate's own threshold — not quoted from either draft. pub const submanifest_peer_version_error_text = "Fossil version 2.29 or later required for sub-manifests."; /// submanifests §7.2 — refuse iff the artifact is S-bearing AND the peer's /// announced version predates sub-manifest support. `carries_s_card` is the /// draft's own disjunction already collapsed by the caller: "a local artifact /// is of type SUBMANIFEST, or is a manifest carrying an S card" — one predicate, /// because the gate treats the two identically and a caller that has classified /// the artifact knows both. /// /// Pure in `peer_version`, exactly like `sha3PeerVersionGate`: choosing the /// default for an unrecorded peer is side-dependent (§9.1 L1853-1862) and so /// stays the caller's decision. On `.refuse` the caller emits /// `writeSignalCard(.err, submanifest_peer_version_error_text)` — never a /// converted, downgraded, or omitted artifact. /// /// Reuses `GateDecision` rather than minting a parallel two-variant enum: the /// §7.6 rationale carries over verbatim — there is no third "downgrade" /// outcome, and sharing the type keeps that structurally true of both gates. pub fn submanifestPeerVersionGate(peer_version: u32, carries_s_card: bool) GateDecision { const d: GateDecision = if (carries_s_card and peer_version < submanifest_min_peer_version) .refuse else .emit; // An artifact carrying no S card is never refused at any version: the gate // is incapable of touching traffic that predates this draft. std.debug.assert(d == .emit or carries_s_card); return d; } /// submanifests §8.1 — "a project-scope boolean, default false". Named rather /// than written as a bare `false` at each site so the default is one fact. pub const submanifest_emission_default: bool = false; /// submanifests §8.1/§7.2/§8.2 — the consequence an operator MUST be shown /// before enabling emission. Enabling is an opt-in with a cost, not a feature /// flag: from the first tree it writes, this repository refuses every pre-2.29 /// peer that artifact (§7.2) and becomes a disposable test fixture (§8.2). The /// text names both, because both are irreversible in practice — §8.1's /// "Disabling the setting ... cannot retire existing ones". pub const submanifest_enable_warning = "enabling sub-manifests makes this repository write tree manifests; from the first one, " ++ "peers below Fossil 2.29 will be refused every S-bearing artifact rather than " ++ "receive a converted or downgraded one, and disabling the setting later cannot retire " ++ "the artifacts already written. While this specification is provisional " ++ "a repository containing an S-bearing artifact must not be synchronized with any " ++ "repository that is not itself a disposable test fixture."; /// submanifests §7.2 — does this implementation read §5.1 trees? zit does: the /// walk, the §7.1 phantom duty, the §7.3 deferral record and the §5.2 refusal /// to present a partial closure are all here. Named rather than written as a /// bare `true` so the one fact `announcedVersion` turns on is greppable. pub const submanifest_reader_support: bool = true; /// submanifests §7.2/[FossilSync] §10.1 — the release integer this peer /// announces, given the one it would otherwise announce and whether it reads /// sub-manifests. /// /// A raise, never a lower: a peer that already announces a higher release /// integer keeps it. /// /// The key is READER SUPPORT, not §8.1's emission setting. §10.1's version is /// what the implementation IS, and §7.2 gates on "the remote peer's announced /// version" — a statement of capability the sender reads to decide whether the /// peer can parse what it is about to receive. Keying it on the emission /// setting instead collapses §8's layer 2 (transfer) into its layer 1 /// (emission): the announcement would then mean "I intend to WRITE S cards", /// which is not what a sender needs to know and not what §10.1 says a version /// is. It also livelocks the feature — a fresh clone target has neither the /// setting nor the §8.1 indicator, so no tree could ever reach a new /// repository, while §8.2 plainly contemplates a tree being "cloned to" a /// fixture. /// /// §8.2's provisional prohibition is NOT weakened by this: it is enforced where /// §8 puts it, at layer 1. `clientMayEmitSCards` is false by default, so no zit /// mints an S-bearing artifact unless an operator opts the repository in; a /// repository that holds none announces the raise and never has anything for /// the gate to refuse. What the announcement buys is the other direction — /// fossil, at 22800, is below the threshold, so an opted-in zit refuses it every /// tree rather than handing it artifacts §8 calls "unreadable". pub fn announcedVersion(base: u32, reader_support: bool) u32 { const v = if (reader_support) @max(base, submanifest_min_peer_version) else base; std.debug.assert(v >= base); // never lowers what the peer already announced std.debug.assert(reader_support or v == base); // no reader ⇒ byte-identical announcement return v; } /// submanifests §7.2/§8.1 — may a commit created in this session emit S cards? /// Both gates in one predicate, because the draft states them as one /// conjunction: §8.1 "A writer MUST NOT emit an S card unless the repository's /// enable-submanifests setting ... is true, and MUST NOT emit one in a commit /// against a server from which it has received the avoid-submanifests pragma in /// the current sync session (Section 7.2)." /// /// The writer itself is out of this slice's scope; this is the decision the /// writer will read, pinned where the pragma that feeds it is parsed. pub fn clientMayEmitSCards(setting_enabled: bool, avoid_received: bool) bool { return setting_enabled and !avoid_received; } // --------------------------------------------------------------------------- // 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" (§4.3 L928-929), so this // feeds no §3.7 continuation condition. cookie_stored: bool = false, // §7.5 L1362-1363 — 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 L1363-1367 — 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 L1374-1376 — a private igot marked an already-held phantom private. phantom_marked_private: bool = false, // §7.5 L1376-1377 — 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, // §10.1 L2064-2067 project_code_mismatch, // §9.1 -> error card, §10.1 L2093-2096 auth_failure, // §6 -> error card, §10.1 L2093-2096 peer_version_refusal, // §7.6 L1387-1394 -> error card // client-detected faults -- fatal in every state, round0 included. unknown_operator, // §3.9 L803-806 / §5 malformed_content, // §3.9 L803-806 / §7 over_length_line, // §5 L2666 redirect_limit, // §4 L845-846 / Table-3 r15 clone_seqno_negative, // §8.2 L1936-1937 }; /// §10.1 L2093-2096, §7.6 L1387-1394 — 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 L628-629: 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 L331-332, 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), L511-514 — 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 L532-535, §3.2 L412-413 — 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), L511-514 — 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 L527-530 — "the ordinary phantom scan excludes … names in the /// repository shun table …". The repository shun table as the phantom scan /// needs it: not a table, one question — is this WIRE NAME shunned? A borrowed /// capability (context + fn pointer, shaped like `repostate.ArtifactStore`), so /// the protocol layer never learns whether the answer comes from a log scan, a /// test fixture, or nothing at all. §3.3 L518-520 makes the set repository-local /// and durable, distinct from the round-local tables, which is exactly why it /// arrives from outside rather than living on `Client`. /// /// KEY DOMAIN. §3.3 L518-519 keys the table by COMPLETE artifact name, and the /// wire carries hex names, so `contains` takes the name verbatim — the caller /// never truncates, pads, or prefix-matches it, and any hex→digest decode /// belongs wholly inside one implementation. The table's keys are artifact-format /// §3.2 L347-350's two complete forms, so a name of any other width cannot be in /// it: `contains` answers `false` for one without consulting the implementation. /// /// A CHECKED REJECTION, not an assert, and that is deliberate. Three boundaries /// keep a name of another width out of the phantom set in the first place — /// `parseIgotCard`, `parseGimmeCard`, and `receiveDelta`'s DELTASRC gate — so /// every name that reaches a scan today is complete. But this is an INTERFACE: /// its callers are whoever holds a `ShunSet`, which no implementation can /// enumerate, and the two things that would reach it first if a fourth mint were /// added are `unresolvedPhantoms` and the §3.3 gimme walk, both driven by peer /// input. An assert there is a peer-reachable trap in Debug and ReleaseSafe and /// nothing at all in ReleaseFast — the wrong instrument in both directions. The /// answer is not a fail-open concession either: `putShun` refuses a non-complete /// key and the open scan fails `error.Corrupt` on one, so no shun row can name a /// name of that width and `false` is the exact truth. /// /// FRESHNESS. The PROTOCOL layer probes this on every scan and caches nothing: /// `buildRequest` and `Server.emitReply` ask per phantom, per round, so an /// implementation whose answer changes is honoured by the very next scan. What /// makes the answer change is the implementation's business, not this layer's — /// see `RepoShunSet` in `src/cmd/sync.zig` for what the CLI's durable set does /// and does not re-read. pub const ShunSet = struct { context: *anyopaque, vtable: *const VTable, pub const VTable = struct { /// Is `name` — a wire artifact name, exactly as it appeared on the wire /// — in the repository shun table? Total: no allocation, no error, no /// I/O. An out-of-domain name answers false, never traps. contains: *const fn (context: *anyopaque, name: []const u8) bool, }; pub fn contains(self: ShunSet, name: []const u8) bool { // The key-domain gate, enforced where it is claimed — as a rejection, for // the reason the doc block above gives. No shun row can key a name that is // not a complete artifact name, so `false` is the exact answer and the // implementation need never see one. if (name.len != 40 and name.len != 64) return false; return self.vtable.contains(self.context, name); } /// The identity: an empty shun table. §3.3 L523-525 — "Omitting that /// exchange does not omit the local set, which is empty when no local or /// exchanged entries supply names." Every construction site that names no /// shun set behaves exactly as it did before the set existed. pub const none: ShunSet = .{ .context = @constCast(&none_context), .vtable = &none_vtable }; /// `context` is `*anyopaque`, which cannot be null, so the empty set still /// needs an address; this byte is never read. const none_context: u8 = 0; const none_vtable: VTable = .{ .contains = noneContains }; fn noneContains(context: *anyopaque, _: []const u8) bool { // Negative space. Discarding the context is only sound while this vtable // is paired with `none_context`, and nothing else observes the pairing: a // construction site that pairs a LIVE context with `none_vtable` // silently disables the filter, and one that pairs `none_context` with a // real vtable writes through a const global. A mispairing is a local // construction error, never peer input, so an assert is the right // instrument — and it costs nothing in release. std.debug.assert(context == ShunSet.none.context); return false; } }; /// §3.3 (a)/(b)/(c), L527-530 — the freshly-per-round (L516-521) gimme eligibility /// filter inputs. pub const Eligibility = struct { peer_lacks: bool, shunned: bool, private: bool, private_sync_enabled: bool, }; /// §3.3, L527-530 — 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 L532-533, §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 L511-512, §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 L532-533: 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 L2491-2492, 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 L1489-1491 — 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 L1487 — write `clone VERSION SEQNO`. SEQNO is a blob-table row /// identifier (§8.2 L1504), 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 L1519-1525 — 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 L1581-1585 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 L1493-1498 fatal error; `malformed` is any other /// shape, which §8.1 L1498-1500 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 L1487-1500 — 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 L1493-1497 if (version == @as(i64, clone_version) and seqno > 0) return .{ .accept = seqno }; // §8.1 L1489 return .malformed; } /// §8.1 L1499-1500 — "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 L1211-1216/§8.2 L1517-1519 — 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 L1217-1218). 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 L937-939 — 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 L918-919/§7.2 L1217-1218 — 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 L937-939 — 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 L1212/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 L1214-1216 — 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 L937-939 — 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 L1519-1521 — 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 L1487-1491 — 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 L1493-1498 — 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 L1498-1500: 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 L1498-1500 — a noninteger VERSION with SEQNO 0 is NOT the fatal error. try std.testing.expect(cls("clone x 0") == .malformed); } test "sync §8.1 L1494-1495: 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 L1519-1521: 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 L1211-1218: 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 L1214-1215 — 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 L1217-1218 — reconstructed verbatim } test "sync §7.2 L1214-1216: 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 L1214-1216 — 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 L1217-1218: 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 L1217-1218 — 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 L1217-1218: 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 L1217-1218 — 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 L937-939); 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 L1217-1218 — 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 L1504-1539: 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 L1504-1506). 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 L1520 — 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 L879-887 — 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 L815-817 (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 L819-820 (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 L822-824 (r4) — the sole protocol-defined nonstandard header. pub const ssh_transport_header: []const u8 = "X-Fossil-Transport: SSH"; /// §4 L822-824 (r4) — sent only when the transport is SSH. pub fn needsSshTransportHeader(t: Transport) bool { return switch (t) { .ssh => true, .http, .https, .file => false, }; } /// §4 L818-819 (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 L818-819 (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 L826-828 (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 L826-828 (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 L845-846 (r10) — at most this many redirects are followed for a /// single exchange. pub const max_redirects: usize = 19; /// §4 L845-846 (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 L846-847 (r12) — a redirect MUST NOT downgrade an HTTPS exchange /// to plaintext HTTP. /// /// §11.2 L2191-2195 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 /// (§11.2-r1 L2721-2723); a deployment carrying such traffic MUST use HTTPS, /// SSH, or an equivalent protected transport (§11.2-r2 L2723-2727) and SHOULD /// NOT use plaintext HTTP in production (§11.2-r3 L2727-2729). Refusing an /// HTTPS->HTTP redirect is this library's sole in-boundary contribution; the /// closing §11.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 L846-847 (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 L847-849 — 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 L923-924 (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 L923-924 — 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 L1091-1093: 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 L1100 + 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 L1075-1077 — 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 L2843-2844 — 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, 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 L2843-2845 — 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 L2842 — 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 L1091-1093 — 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 L1079-1081, §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 L1079-1081 — 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 L1079-1080 — 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 L1079-1081, or /// clone round 0 per §8.3 L1598), `.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 L1079-1081 / §8.3 L1598 — no login card emitted. body, // §6.1 L1075 / §6.5 L1181-1182 — body-embedded login card, no x-f-l-c. cookie, // §6.5 L1161-1163,L2135-2136 — x-f-l-c cookie header, no body login. /// §6.5 L1157-1163 — 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 L1075 / §6.5 L1181-1182 — 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 L1159 — 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 L1159 — timestamp on/after 2025-07-27 11:05:00, recorded as /// DATE (YYYYMMDD) / TIME (HHMMSS) per §9.1 L1853-1854. 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 L1159 (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` (§9.1 L1860-1863 seeds /// no timestamp), so the "not yet observed" fallback (§6.5 L1181-1182) 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 L1598, via the shared /// `sendableAfterCloneRound0`) each yield `.none`. Otherwise the peer gate /// (§6.5 L1157-1159) 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 L1078-1081, §6.5 L1184-1185 — 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 L1189-1191 — 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 §9.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 L1189-1191 — "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 L1191-1193 — the established capability set the server checks each /// card against (built §6.4 L1128-1130: 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 L1189-1191 — 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 L1128-1130 — 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 L1125-1127) 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 L1128-1130 additive-only invariant (negative space: no baseline bit is cleared). std.debug.assert(result.supersetOf(baseline)); return result; } /// §6.6 L1189-1191 — 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 L1191-1193 — 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 L1191-1193 — 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 §9.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`, and the caller never lands an unverifiable delta /// under `hash`. /// /// `BasisAbsent` reports only that the basis was not resolvable; WHY decides /// whether it is a fault, and that question belongs to the caller, which alone /// holds the shun table. §7.7 L1444-1450 exempts exactly one cause: a DELTASRC /// in the repository shun table is masked out of the served store, so an /// otherwise conformant peer that deltas against a name it saw us hold BEFORE /// the shun lands here, and the clause prescribes ordinary dangling-delta /// handling rather than a fault. Every other cause — an unknown, truncated, or /// attacker-chosen name — keeps the §7.4/§3.8 fault. `scanCard`'s `file` arm /// draws that line; this function never guesses it. 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 L937-939 — 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 L935-937) — 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, /// §7.7 L1437-1444 — re-asked HERE, not inherited from the round that /// retained the entry. A retained delta lands its target on a LATER /// round, and the shun table is durable and locally mutable (§3.3 /// L518-525) with the scan re-probing it every round, so a shun applied /// between rounds must stop the completion too. A rejected entry is /// dropped exactly as its card would have been: freed, no store /// mutation, no error, and not enqueued as a basis for the cascade. admission: TargetAdmission, ) (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. // // §7.7 L1437-1444 — but only for a target still admitted. The // payload tag IS the card type the L1440 rules split on: `.raw` is // a §7.1 `file` (no container), `.container` a §7.2 `cfile`. const entry = self.items.items[i]; if (admission.admits(entry.hash, entry.payload == .container)) { const target_rid = try self.completeEntry(gpa, store, entry); // 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, entry); _ = 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 L537-540 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 L2483-2493 (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 L1362-1367 (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 L1372-1374 (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 L1374-1376 (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. /// /// HASH must be one of artifact-format §3.2 L347-350's two complete forms /// (40-hex SHA-1 or 64-hex SHA3-256) — "A candidate name of any other length /// MUST be rejected as invalid" — exactly as `parseGimmeCard` already rejects /// one, so the 40/64 width is never assumed downstream on wire input. This is /// the boundary `logstore.LogStore.isShunned`'s caller contract names: §3.3 /// L527-530 puts the shun table in the per-round phantom scan, and a phantom is /// minted from an igot HASH, so an abbreviated name admitted here would become a /// phantom whose key cannot be in a table keyed by complete name (§3.3 L518-519) /// — a hash the shun filter answers "not shunned" for and the scan then requests. /// Rejecting the name here makes that fail-open path unrepresentable instead of /// widening the filter to tolerate it. 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, }; if (result.hash.len != 40 and result.hash.len != 64) 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; return .{ .hash = hash }; } // --------------------------------------------------------------------------- // Section 7.7 — Hash-Policy Admission // --------------------------------------------------------------------------- /// §7.7 L1434-1437 — target admission for one received artifact-transfer card. /// "Admission applies to the received target HASH, not independently to /// DELTASRC. A target is shunned for admission when its complete name is in the /// repository shun table OR when shun-sha1 applies to its SHA1 name." /// /// ONE COMBINED PREDICATE, not two checks. The draft states a single disjunction /// and every call site asks the same question; splitting it into a policy gate /// beside a table gate lets the halves drift apart. What differs between call /// sites is what each one DOES with the answer (§7.7 L1437-1444 — a non-clone /// `file` discards, a clone `file` never asks, a `cfile` discards even in /// clone), so the asymmetry lives at the three call sites, each with its own /// quoted clause, and never inside this function. /// /// Neither input comes from the peer: `policy` is this repository's effective /// hash-policy value (L1428-1432 — "No peer state or artifact-transfer card /// field participates in this predicate") and `shun` is the repository-local /// durable table (§3.3 L518-525). /// /// It never recomputes or inspects content — that is content-integrity's job /// (L1465-1471), a separate question this function is deliberately blind to. /// /// OUT-OF-DOMAIN NAME WIDTH is answered, not asserted. `parseFileCard` and /// `parseCfileCard` validate arity and the byte counts but NOT the hash width, /// so a peer can put any token here; an assert would be reachable from wire /// input (and would vanish in a release build besides). Neither half of the /// predicate can fire on such a name — `nameAlgo` is null so shun-sha1 cannot /// apply, and §3.3 L518-519 keys the table by COMPLETE name — so `admit` is the /// exact answer, and the name goes on to whatever the store layer already did /// with it. pub const Admission = enum { admit, reject }; pub fn admitInbound(policy: artifact.HashPolicy, shun: ShunSet, hash: []const u8) Admission { if (artifact.nameAlgo(hash.len) == null) return .admit; std.debug.assert(hash.len == 40 or hash.len == 64); const sha1_shunned = (policy == .shun_sha1 and hash.len == 40); const table_shunned = shun.contains(hash); return if (sha1_shunned or table_shunned) .reject else .admit; } /// §7.7 L1437-1444 — "may this receiver LAND content under this target name?", /// answered wherever content is about to be stored rather than only where a card /// is parsed. /// /// `admitInbound` is the combined predicate; this adds the one input the /// predicate deliberately excludes — WHICH CARD, in WHICH context — because the /// draft attaches two different rules to the same answer: "A clone `file` /// bypasses this combined target check" (L1440) while for `cfile` "a shunned /// target is consumed and discarded after its payload EVEN IN CLONE" /// (L1440-1444). Card-arm gates alone cannot carry that: a `file`'s bytes can /// land rounds later, out of any arm, when a deferred delta's basis finally /// arrives — and admission is a per-round question (§3.3 L518-525 makes the /// table durable and locally mutable, and the scan re-probes it every round), so /// a shun applied mid-session must reach the completion too. Bundling the three /// inputs is what lets the queue re-ask without knowing about `Client`. /// /// This is CONTROL FLOW, not an assertion: §7.7's "makes no repository mutation" /// must hold in a release build, where `std.debug.assert` compiles to nothing. pub const TargetAdmission = struct { policy: artifact.HashPolicy = .auto, shun: ShunSet = .none, /// §7.7 L1440 — the clone-context predicate the `file` bypass keys on. clone: bool = false, /// `from_cfile` selects which L1440 rule applies: a `cfile` target is always /// asked, a `file` target is not asked at all in clone. pub fn admits(self: TargetAdmission, hash: []const u8, from_cfile: bool) bool { if (self.clone and !from_cfile) return true; return admitInbound(self.policy, self.shun, hash) == .admit; } /// The identity — an empty table under `auto`, outside clone. Every call site /// that has no admission policy to express behaves exactly as it did before /// admission existed: `admits` is unconditionally true. pub const unrestricted: TargetAdmission = .{}; }; /// §7.7 L1465-1471 — 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 L1244-1246 — 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 L964-967); 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, §8.5.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 // --------------------------------------------------------------------------- /// §8.5.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, }; /// §8.5.1 L1667-1685 — 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, }; /// §8.5.1 L1696-1705 — 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 }; /// §8.5.1 L1709-1719 — the send-table effect of an (already revert-mapped) /// status. pub const SendTableOp = enum { remove, mtime_only, keep_full }; /// §8.5.1 L1687-1689 — revert/dry-run posture for one uvigot classification. pub const UvMode = struct { revert: bool = false, dry_run: bool = false, }; /// §8.5.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, }; /// §8.5.1 L1658-1663 — the per-request catalog-emission dedup latch; one /// instance covers one request. pub const ServerUvRequest = struct { catalog_emitted: bool = false, }; /// §8.5.1 L1667-1685 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; } /// §8.5.1 L1687-1688 — 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, }; } /// §8.5.1 L1709-1719 — 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; } /// §8.5.1 L1696-1705, L1688-1689 — 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; } /// §8.5.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; } /// §8.5.1 L1649-1653 — 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); } /// §8.5.1 L1644-1656 — 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; } /// §8.5.1 L1658-1663 — 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 L306-308, §8.5.1 L1639-1640 — 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, /// §8.5.1 L1639-1640 — 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; } /// §8.5.1 L1718-1719 — 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 §8.5.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 §8.5.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 §8.5.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 §8.5.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 §8.5.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 §8.5.1: status 4 keeps send-table entry mtimeOnly true when uploads allowed" { try std.testing.expectEqual(SendTableOp.mtime_only, sendTableOp(.newer, false)); } test "sync §8.5.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 §8.5.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 §8.5.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)); } /// §8.5.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 }; /// §8.5.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 L2469-2474 — 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 L1352-1358 — 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); } } // --------------------------------------------------------------------------- // Tests — draft-fossil-submanifests-00 §7.2 / §8.1. // --------------------------------------------------------------------------- test "submanifests §7.2: avoid-submanifests is a server-to-client arg-less pragma" { // §7.2: "A server whose enable-submanifests setting (Section 8.1) is false // sends the pragma avoid-submanifests, in the same reply positions as // avoid-delta-manifests" — so it joins the catalog with the same direction // and the same (arg-less) shape as the pragma it is named after. const name = parsePragmaName("avoid-submanifests").?; try std.testing.expectEqual(PragmaDirection.server_to_client, pragmaDirection(name)); try std.testing.expectEqual(pragmaDirection(.@"avoid-delta-manifests"), pragmaDirection(name)); try std.testing.expect(pragmaIsArgless(name)); var buf: [64]u8 = undefined; try std.testing.expectEqualStrings("pragma avoid-submanifests", try writeFlagPragma(name, &buf)); } test "submanifests §7.2: a server whose §8.1 setting is false sends avoid-submanifests, and one whose setting is true does not" { // The emission half of §7.2's sentence — the receipt half is pinned above // and below. The condition is the SETTING, so this walks both states over // both reply shapes: the ordinary §3.2 reply and the §8.2 clone reply, which // is a separate emitter and would otherwise be able to drift. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); _ = try ms.store().put("a" ** 64, "content\n"); const requests = [_][]const u8{ "pragma client-version 22700\ngimme " ++ "a" ** 64 ++ "\n", // §3.2 reply "pragma client-version 22700\nclone 3 1\n", // §8.2 clone reply }; for (requests) |req| { for ([_]bool{ false, true }) |enabled| { const server = Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = enabled, }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), req, &out); const present = std.mem.indexOf(u8, reply, "pragma avoid-submanifests\n") != null; try std.testing.expectEqual(!enabled, present); // "in the same reply positions as avoid-delta-manifests": the // leading pragma run. With an undated client-version there is no // server-version (§10.1 r12), so the pragma opens the reply. if (present) try std.testing.expect(std.mem.startsWith(u8, reply, "pragma avoid-submanifests\n")); } } } test "submanifests §7.2/§8.1: the emitted pragma keys on the setting alone, not on the armed disjunction" { // A repository that HOLDS trees but has emission off (§8.1: "Disabling the // setting ... cannot retire existing ones") must still tell a client not to // author new ones. Keying the pragma on `submanifestGateArmed` — the // disjunction the SEND gate reads — would silently stop advertising the // refusal for exactly those repositories. // // This is the ONE state in which `submanifestGateArmed` and // `advertisesAvoidSubmanifests` disagree, so it is what stands between the // two predicates and a plausible-looking "these say almost the same thing, // unify them" cleanup. The test fails in either direction: rewrite the // pragma to read the armed disjunction and the reply assertion goes; rewrite // armed to the setting alone and the arming assertion goes. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); _ = try ms.store().put("a" ** 64, "content\n"); const server = Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = false, // §8.1 setting off … .seen_submanifest = true, // … but the repository already holds trees }; try std.testing.expect(server.submanifestGateArmed()); // the send gate IS armed try std.testing.expect(server.advertisesAvoidSubmanifests()); // … and the pragma still fires var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\ngimme " ++ "a" ** 64 ++ "\n", &out); try std.testing.expect(std.mem.indexOf(u8, reply, "pragma avoid-submanifests\n") != null); } test "submanifests §7.2: a client latches the pragma straight off a real server reply" { // End to end across the two halves: the server emits, the client's // `consumeReply` latches, and `clientMayEmitSCards` then refuses S cards for // the session even for a client whose own §8.1 setting is on. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); _ = try ms.store().put("a" ** 64, "content\n"); const server = Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = false, }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\ngimme " ++ "a" ** 64 ++ "\n", &out); var client_ms = repostate.MemStore.init(gpa); defer client_ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .submanifests_enabled = true }; defer client.deinit(gpa); try std.testing.expect(clientMayEmitSCards(client.submanifests_enabled, client.avoid_submanifests)); _ = try client.consumeReply(gpa, client_ms.store(), reply); try std.testing.expect(client.avoid_submanifests); try std.testing.expect(!clientMayEmitSCards(client.submanifests_enabled, client.avoid_submanifests)); } test "submanifests §7.2: a client that received avoid-submanifests emits no S card" { // §7.2: "a client receiving it MUST NOT emit S cards in commits created // during the session." try std.testing.expect(clientMayEmitSCards(true, false)); try std.testing.expect(!clientMayEmitSCards(true, true)); // §8.1: the setting is the outer gate — a client that never received the // pragma still emits nothing while the setting is off. try std.testing.expect(!clientMayEmitSCards(false, false)); try std.testing.expect(!clientMayEmitSCards(false, true)); } test "submanifests §7.2: a re-announced version does not re-offer what was withheld" { // The withheld set is what makes the exchange terminate: an artifact the // gate refuses is recorded so the next round does not offer it again. A // peer that RAISES its version earns a fresh answer, but a pre-S peer // re-announces the SAME version in every reply, and clearing on each // announcement re-offers what it has already refused -- the exchange then // never converges. The trigger is the change, not the announcement. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // Round 1: the peer announces. This is genuinely new (the version was // unknown), so it is not the case under test. _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n"); // The gate then refuses an artifact to this peer and records it. client.state = .exchange; try client.submanifests_withheld.put(std.testing.allocator, 7, {}); // Round 2: the SAME peer re-announces the SAME version, as fossil 2.28 // does in every reply. The record must survive. _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n"); try std.testing.expect(client.submanifests_withheld.contains(7)); client.state = .exchange; _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n"); try std.testing.expect(client.submanifests_withheld.contains(7)); // and again // A raise is a genuinely fresh answer, so the record is dropped. client.state = .exchange; _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 23000\n"); try std.testing.expect(!client.submanifests_withheld.contains(7)); } test "submanifests §7.2: consuming the pragma latches the session's no-S-card rule" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expect(!client.avoid_submanifests); _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma avoid-submanifests\n"); try std.testing.expect(client.avoid_submanifests); // Latched for the SESSION (§7.2: "during the session"), so a later round // that carries no pragma does not clear it. client.state = .exchange; // next round of the same session _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n"); try std.testing.expect(client.avoid_submanifests); } test "submanifests §7.2: the peer-version gate refuses an S-bearing artifact to a pre-S peer" { // §7.2: "When the remote peer's announced version predates sub-manifest // support ... and a local artifact is of type SUBMANIFEST, or is a manifest // carrying an S card, the sender MUST refuse to transmit that artifact, // with an explicit error -- never by converting, downgrading, or silently // omitting it." try std.testing.expectEqual(GateDecision.refuse, submanifestPeerVersionGate(22800, true)); try std.testing.expectEqual(GateDecision.refuse, submanifestPeerVersionGate(0, true)); try std.testing.expectEqual(GateDecision.emit, submanifestPeerVersionGate(submanifest_min_peer_version, true)); // Every artifact that carries no S card is unaffected at every version — // the gate can never touch pre-existing traffic. try std.testing.expectEqual(GateDecision.emit, submanifestPeerVersionGate(0, false)); try std.testing.expectEqual(GateDecision.emit, submanifestPeerVersionGate(22800, false)); // The threshold is strictly above the newest released Fossil, which has no // sub-manifest support at all. try std.testing.expect(submanifest_min_peer_version > 22800); } /// The two-artifact fixture the §7.2 send-gate tests share: one manifest /// carrying an S card and one flat manifest that does not, so every assertion /// below can say what the gate did AND what it left alone in the same reply. const SGateFixture = struct { tree: []const u8 = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ ("1" ** 64) ++ "\nU u\n", flat: []const u8 = "C m\nD 2026-07-27T12:00:00.000\nF a.txt " ++ ("2" ** 64) ++ "\nU u\n", tree_name: [64]u8 = undefined, flat_name: [64]u8 = undefined, fn init(self: *SGateFixture, ms: *repostate.MemStore) !void { _ = artifact.artifactName(.sha3_256, self.tree, &self.tree_name); _ = artifact.artifactName(.sha3_256, self.flat, &self.flat_name); _ = try ms.store().put(&self.tree_name, self.tree); _ = try ms.store().put(&self.flat_name, self.flat); } /// A pull request announcing `peer_version` and gimme-ing both artifacts. fn request(self: *const SGateFixture, gpa: std.mem.Allocator, peer_version: u32) ![]u8 { return std.fmt.allocPrint( gpa, "pragma client-version {d}\npull 0000000000000000 2222222222222222\ngimme {s}\ngimme {s}\n", .{ peer_version, &self.tree_name, &self.flat_name }, ); } /// The §7.2 refusal AS IT APPEARS ON THE WIRE — an `error` card carries its /// text §3.4-escape-encoded, so a test matching the raw literal would pass /// against a card that never left. Built through the same writer the server /// uses, so the two cannot drift. fn refusalCard(out: []u8) []u8 { return writeSignalCard(.err, submanifest_peer_version_error_text, out); } }; test "submanifests §7.2: the send path refuses an S-bearing artifact to a pre-S peer, with an explicit error" { // §7.2: "the sender MUST refuse to transmit that artifact, with an explicit // error -- never by converting, downgrading, or silently omitting it." // The gate is armed by §8.1: a repository that has opted into // sub-manifests is the only one that can hold one to refuse. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = true, }; const req = try fx.request(gpa, 22700); // below submanifest_min_peer_version defer gpa.free(req); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), req, &out); // Refused with an explicit error, and the artifact's bytes are NOT on the // wire — the draft rules out silent omission and refusal-without-error // alike, so both halves are asserted. var card_buf: [max_line_len]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) != null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.tree) == null); // The artifact carrying no S card is untouched at the same version: the // gate is incapable of reaching traffic that predates this draft. try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null); } test "submanifests §7.2: an opted-in peer at or above the threshold receives the S-bearing artifact" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = true, }; // A peer that announces sub-manifest support — which, per §7.2, is what any // peer that READS trees announces (`announcedVersion`). const req = try fx.request(gpa, submanifest_min_peer_version); defer gpa.free(req); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), req, &out); var card_buf: [max_line_len]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) == null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.tree) != null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null); } test "submanifests §7.2/§8.1: an UNARMED repository's send path is byte-identical to the pre-draft one" { // The compatibility contract as a test. A repository with neither §8.1 // disjunct — no emission setting, no seen-submanifest indicator, which is // every repository predating this draft — sends exactly what it sent before // this draft existed, to any peer. // // The S-bearing artifact in the store here is a FIXTURE, not a reachable // state: `persistStore` sets the indicator for anything §7.2 gates, so a // real repository holding this artifact is armed. What the test pins is that // the gate is skipped — not merely inert — when neither disjunct holds, so // an untouched repository pays nothing and behaves identically. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); const gated = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = false, // the default, stated }; const req = try fx.request(gpa, 22700); defer gpa.free(req); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try gated.respond(gpa, ms.store(), req, &out); var card_buf: [max_line_len]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) == null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.tree) != null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null); } test "submanifests §7.2: the upload path withholds an S-bearing artifact from a pre-S peer and says so" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); var client = Client{ .operation = .push, .state = .exchange, .self_servercode = "0000000000000000", .project_code = "2222222222222222", .version = .{ .version = 22700 }, .submanifests_enabled = true, .peer = .{ .version = 22800 }, // a peer below the threshold }; defer client.deinit(gpa); // Queue both artifacts for upload, exactly as the §3.3 send gate would. try client.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {}); try client.send_queue.put(gpa, ms.store().lookup(&fx.flat_name).?, {}); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const req = try client.buildRequest(gpa, ms.store(), &out); // §7.2 — the S-bearing artifact is not on the wire, the other one is, and // the refusal is COUNTED so the CLI can report it: never silently omitted. try std.testing.expect(std.mem.indexOf(u8, req, fx.tree) == null); try std.testing.expect(std.mem.indexOf(u8, req, fx.flat) != null); try std.testing.expectEqual(@as(usize, 1), client.submanifests_refused); try std.testing.expectEqual(@as(usize, 1), client.content_sent); } test "submanifests §7.2: an armed client announces the threshold and uploads to a peer at or above it" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); var client = Client{ .operation = .push, .state = .exchange, .self_servercode = "0000000000000000", .project_code = "2222222222222222", .version = .{ .version = 22700 }, .submanifests_enabled = true, .peer = .{ .version = submanifest_min_peer_version }, }; defer client.deinit(gpa); try client.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {}); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const req = try client.buildRequest(gpa, ms.store(), &out); try std.testing.expect(std.mem.indexOf(u8, req, fx.tree) != null); try std.testing.expectEqual(@as(usize, 0), client.submanifests_refused); // §7.2 — the announcement is what stops a conforming peer refusing this // side every tree, and it is `announcedVersion`'s raise, not the client's // own release integer. var vbuf: [64]u8 = undefined; const expect_pragma = try writeClientVersion(.{ .version = submanifest_min_peer_version }, &vbuf); try std.testing.expect(std.mem.startsWith(u8, req, expect_pragma)); } test "submanifests §7.2: a server announces the threshold because it READS trees, not because it emits them" { // The SERVER half of the announcement, which the client half above depends // on: §7.2's gate reads "the remote peer's announced version", so a server // that does NOT raise its own announcement is refused every tree by a // conforming client — the trees vanish from the push and the server never // learns why. `announcedVersionInfo` is what prevents that, and it is only // ever observable through the emitted pragma, so the assertion is over the // reply bytes rather than over the function. // // The announcement does NOT depend on §8.1's emission setting. It cannot: // a fresh clone target has neither the setting nor the seen-submanifest // indicator, so an announcement keyed on either one would make a tree // unable to reach a new repository at all, while §8.2 plainly contemplates // one being "cloned to" a fixture. §8 keeps emission and transfer as // separate layers; so does this. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); // §10.1 r11/r12 — the server emits server-version exactly when the received // client-version carried DATE and TIME, so the request carries both. const req = "pragma client-version 22700 20260727 120000\npull 0000000000000000 2222222222222222\n"; const dt: VersionInfo = .{ .version = 22800, .datetime = .{ .date = 20260727, .time = 120000 } }; // Built through the same writer the server uses, so the expectation and the // emission cannot drift. var vbuf: [64]u8 = undefined; const raised = try writeServerVersion( .{ .version = submanifest_min_peer_version, .datetime = dt.datetime }, &vbuf, ); var vbuf2: [64]u8 = undefined; const base = try writeServerVersion(dt, &vbuf2); // Both arming states announce the SAME raised version — the announcement is // a statement about the reader, and the reader is the same either way. for ([_]bool{ true, false }) |enabled| { const server = Server{ .version = dt, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = enabled, }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), req, &out); try std.testing.expect(std.mem.indexOf(u8, reply, raised) != null); try std.testing.expect(std.mem.indexOf(u8, reply, base) == null); } } test "submanifests §8.1/§7.2: the §8.1 indicator arms the upload gate on a repository whose setting is OFF" { // THE LEAK THIS PINS. §8.1: "Disabling the setting stops the emission of new // tree manifests; it cannot retire existing ones." A repository can // therefore HOLD trees with `enable-submanifests` false — which is the state // of any zit whose operator ran `zit submanifest disable` after committing, // and of every zit that received a tree by sync without ever enabling it. // A gate armed on the setting alone is `.emit` for all of them, and every // such repository uploads its trees ungated to a pre-S peer: §8's transfer // layer collapsed into its emission layer. Arming on the durable // seen-submanifest indicator as well is what closes it. // // Remove `or self.seen_submanifest` from `Client.submanifestGateArmed` and // the first half of this test fails: the tree lands in the request body. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); var armed = Client{ .operation = .push, .state = .exchange, .self_servercode = "0000000000000000", .project_code = "2222222222222222", .version = .{ .version = 22700 }, .peer = .{ .version = 22800 }, // a pre-S peer .submanifests_enabled = false, // §8.1's default, stated .seen_submanifest = true, // …but this repository already holds a tree }; defer armed.deinit(gpa); try armed.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {}); try armed.send_queue.put(gpa, ms.store().lookup(&fx.flat_name).?, {}); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const req = try armed.buildRequest(gpa, ms.store(), &out); try std.testing.expect(std.mem.indexOf(u8, req, fx.tree) == null); try std.testing.expect(std.mem.indexOf(u8, req, fx.flat) != null); try std.testing.expectEqual(@as(usize, 1), armed.submanifests_refused); // The compatibility contract, stated where it now lives: a repository with // NEITHER disjunct — every repository predating this draft — withholds // nothing and never reaches the content predicate at all. var unarmed = Client{ .operation = .push, .state = .exchange, .self_servercode = "0000000000000000", .project_code = "2222222222222222", .version = .{ .version = 22700 }, .peer = .{ .version = 22800 }, }; defer unarmed.deinit(gpa); try std.testing.expect(!unarmed.submanifestGateArmed()); try unarmed.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {}); var out2: std.ArrayListUnmanaged(u8) = .empty; defer out2.deinit(gpa); const req2 = try unarmed.buildRequest(gpa, ms.store(), &out2); try std.testing.expect(std.mem.indexOf(u8, req2, fx.tree) != null); try std.testing.expectEqual(@as(usize, 0), unarmed.submanifests_refused); } test "submanifests §8.1/§7.2: the §8.1 indicator arms the reply and clone gates on a setting-OFF server" { // The server half of the same leak. Every zit `serve` today runs with // `enable-submanifests` false — there is no operator surface for it — so a // gate armed on the setting alone is permanently `.emit` and a repository // that HOLDS a tree serves it to fossil, which §8 says can only reject it as // structural and hold it as opaque content. // // Remove `or self.seen_submanifest` from `Server.submanifestGateArmed` and // both halves fail: the leaf lands in the reply and in the clone batch. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SLeafFixture = .{}; try fx.init(&ms); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = false, // §8.1's default, stated .seen_submanifest = true, // …but this repository already holds a leaf }; var card_buf: [max_line_len]u8 = undefined; const req = try fx.request(gpa, 22800); // fossil 2.28: below the threshold defer gpa.free(req); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), req, &out); try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) != null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.leaf) == null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null); var scan: RequestScan = .{}; defer scan.deinit(gpa); scan.client_version = .{ .version = 22800 }; var out2: std.ArrayListUnmanaged(u8) = .empty; defer out2.deinit(gpa); var line_buf: [max_line_len]u8 = undefined; try server.emitCloneReply(gpa, ms.store(), &scan, &out2, &line_buf, 1); try std.testing.expect(std.mem.indexOf(u8, out2.items, SGateFixture.refusalCard(&card_buf)) != null); try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.leaf_name) == null); try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.flat_name) != null); // Neither disjunct — every repository predating this draft — serves both, // with no gate and therefore no inflate. const unarmed = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", }; try std.testing.expect(!unarmed.submanifestGateArmed()); var out3: std.ArrayListUnmanaged(u8) = .empty; defer out3.deinit(gpa); const plain = try unarmed.respond(gpa, ms.store(), req, &out3); try std.testing.expect(std.mem.indexOf(u8, plain, SGateFixture.refusalCard(&card_buf)) == null); try std.testing.expect(std.mem.indexOf(u8, plain, fx.leaf) != null); } test "submanifests §7.2: a clone to a pre-S peer refuses the S-bearing artifact, and an ordinary clone never inflates" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SGateFixture = .{}; try fx.init(&ms); const armed = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = true, }; var scan: RequestScan = .{}; defer scan.deinit(gpa); scan.client_version = .{ .version = 22700 }; // pre-S peer var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); var line_buf: [max_line_len]u8 = undefined; try armed.emitCloneReply(gpa, ms.store(), &scan, &out, &line_buf, 1); // §7.2 — refused with the explicit error, and the batch the peer CAN read // still lands (the §11.1 r8 append disposition). var card_buf: [max_line_len]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, out.items, SGateFixture.refusalCard(&card_buf)) != null); try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.tree_name) == null); try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.flat_name) != null); // The default server — every unarmed repository — serves both, // with no gate and therefore no inflate: the O(wire) clone is untouched. const plain = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", }; var out2: std.ArrayListUnmanaged(u8) = .empty; defer out2.deinit(gpa); try plain.emitCloneReply(gpa, ms.store(), &scan, &out2, &line_buf, 1); try std.testing.expect(std.mem.indexOf(u8, out2.items, SGateFixture.refusalCard(&card_buf)) == null); try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.tree_name) != null); try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.flat_name) != null); } /// submanifests §7.2 SECOND SHAPE — a LEAF sub-manifest beside a flat manifest. /// /// A leaf carries F cards and no S card at all (§4.4: a SUBMANIFEST carrying /// both F and S is invalid), so it is precisely the artifact an S-card-only /// gate ships to a peer that cannot read it. §7.2 gates it by TYPE: "a local /// artifact is of type SUBMANIFEST, or is a manifest carrying an S card". const SLeafFixture = struct { leaf: []const u8 = "F a.txt " ++ ("3" ** 64) ++ "\nF b.txt " ++ ("4" ** 64) ++ "\n", flat: []const u8 = "C m\nD 2026-07-27T12:00:00.000\nF a.txt " ++ ("2" ** 64) ++ "\nU u\n", leaf_name: [64]u8 = undefined, flat_name: [64]u8 = undefined, fn init(self: *SLeafFixture, ms: *repostate.MemStore) !void { _ = artifact.artifactName(.sha3_256, self.leaf, &self.leaf_name); _ = artifact.artifactName(.sha3_256, self.flat, &self.flat_name); _ = try ms.store().put(&self.leaf_name, self.leaf); _ = try ms.store().put(&self.flat_name, self.flat); // The premise the whole case rests on, pinned rather than assumed: the // leaf IS type SUBMANIFEST under §4.3 and carries NO S card, so a gate // keyed on S-bearingness alone is structurally blind to it. The flat // manifest carries a D card and is therefore never gated (§4.3). try std.testing.expect(!repostate.sBearingCandidate(self.leaf)); try std.testing.expect(repostate.transferGatedArtifact(self.leaf)); try std.testing.expect(!repostate.transferGatedArtifact(self.flat)); } /// A pull request announcing `peer_version` and gimme-ing both artifacts. fn request(self: *const SLeafFixture, gpa: std.mem.Allocator, peer_version: u32) ![]u8 { return std.fmt.allocPrint( gpa, "pragma client-version {d}\npull 0000000000000000 2222222222222222\ngimme {s}\ngimme {s}\n", .{ peer_version, &self.leaf_name, &self.flat_name }, ); } }; test "submanifests §7.2: the reply gate refuses a LEAF sub-manifest exactly where it refuses a tree manifest" { // The gap this pins: `sBearingCandidate` is FALSE for every leaf, so a gate // reading only it refuses the tree manifest and then ships the leaves that // carry the tree's actual file entries — §8's "unreadable ... held as opaque // content" delivered to a peer that asked for a check-in. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SLeafFixture = .{}; try fx.init(&ms); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = true, }; const req = try fx.request(gpa, 22700); // below submanifest_min_peer_version defer gpa.free(req); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), req, &out); var card_buf: [max_line_len]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) != null); try std.testing.expect(std.mem.indexOf(u8, reply, fx.leaf) == null); // …and the artifact that predates this draft still lands, so the gate is // proven to be discriminating rather than merely refusing the round. try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null); // An opted-in peer at the threshold receives it: the gate is the peer // version, not a ban on leaves. const req_ok = try fx.request(gpa, submanifest_min_peer_version); defer gpa.free(req_ok); var out_ok: std.ArrayListUnmanaged(u8) = .empty; defer out_ok.deinit(gpa); const reply_ok = try server.respond(gpa, ms.store(), req_ok, &out_ok); try std.testing.expect(std.mem.indexOf(u8, reply_ok, SGateFixture.refusalCard(&card_buf)) == null); try std.testing.expect(std.mem.indexOf(u8, reply_ok, fx.leaf) != null); } test "submanifests §7.2: the upload path withholds a LEAF sub-manifest from a pre-S peer and counts it" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SLeafFixture = .{}; try fx.init(&ms); var client = Client{ .operation = .push, .state = .exchange, .self_servercode = "0000000000000000", .project_code = "2222222222222222", .version = .{ .version = 22700 }, .submanifests_enabled = true, .peer = .{ .version = 22800 }, // a peer below the threshold }; defer client.deinit(gpa); try client.send_queue.put(gpa, ms.store().lookup(&fx.leaf_name).?, {}); try client.send_queue.put(gpa, ms.store().lookup(&fx.flat_name).?, {}); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const req = try client.buildRequest(gpa, ms.store(), &out); try std.testing.expect(std.mem.indexOf(u8, req, fx.leaf) == null); try std.testing.expect(std.mem.indexOf(u8, req, fx.flat) != null); // §7.2 forbids SILENT omission; the count is what makes the refusal audible. try std.testing.expectEqual(@as(usize, 1), client.submanifests_refused); try std.testing.expectEqual(@as(usize, 1), client.content_sent); } test "submanifests §7.2: a clone to a pre-S peer refuses a LEAF sub-manifest too" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var fx: SLeafFixture = .{}; try fx.init(&ms); const armed = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", .submanifests_enabled = true, }; var scan: RequestScan = .{}; defer scan.deinit(gpa); scan.client_version = .{ .version = 22700 }; // pre-S peer var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); var line_buf: [max_line_len]u8 = undefined; try armed.emitCloneReply(gpa, ms.store(), &scan, &out, &line_buf, 1); var card_buf: [max_line_len]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, out.items, SGateFixture.refusalCard(&card_buf)) != null); try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.leaf_name) == null); try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.flat_name) != null); } test "submanifests §7.2/§4.3: the gate's content predicate is exactly the section's two shapes" { // Shape one: type SUBMANIFEST. A leaf (F run, no D card) and an interior // node (S run, no D card) are both SUBMANIFEST under §4.3's reassignment. try std.testing.expect(repostate.transferGatedArtifact("F a.txt " ++ ("1" ** 64) ++ "\n")); try std.testing.expect(repostate.transferGatedArtifact("S a.txt " ++ ("1" ** 64) ++ "\n")); // Shape two: a MANIFEST carrying an S card — it has a D card, so §4.3 does // NOT reassign it; only the S run puts it inside the gate. try std.testing.expect(repostate.transferGatedArtifact( "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ ("1" ** 64) ++ "\nU u\n", )); // Everything that predates this draft is OUTSIDE the gate — the whole // backwards-compatibility contract, stated over the predicate itself. try std.testing.expect(!repostate.transferGatedArtifact( "C m\nD 2026-07-27T12:00:00.000\nF a.txt " ++ ("2" ** 64) ++ "\nU u\n", )); // a flat check-in manifest try std.testing.expect(!repostate.transferGatedArtifact( "C m\nD 2026-07-27T12:00:00.000\nB " ++ ("2" ** 64) ++ "\nF a.txt " ++ ("3" ** 64) ++ "\nU u\n", )); // a delta manifest try std.testing.expect(!repostate.transferGatedArtifact("M " ++ ("2" ** 64) ++ "\n")); // a cluster try std.testing.expect(!repostate.transferGatedArtifact("hello, world\n")); // a file blob try std.testing.expect(!repostate.transferGatedArtifact("no trailing newline")); // not a card run try std.testing.expect(!repostate.transferGatedArtifact("")); // no cards at all // A FILE BLOB whose CONTENT holds a line beginning `S `. §7.2's gated set is // "of type SUBMANIFEST, or a manifest carrying an S card"; a blob is // neither, whatever its prose spells. `sBearingCandidate` is true for it by // design — it is a pre-filter with a real parse behind it at the §7.1 // phantomize sites — but there is no parse behind the transfer gate, so a // gate reading it directly REFUSES ordinary source, prose and diffs to every // peer below the threshold. The fixture is the absorb-side battery's own. try std.testing.expect(repostate.sBearingCandidate("some prose\nS is the twenty-second letter\n")); try std.testing.expect(!repostate.transferGatedArtifact("some prose\nS is the twenty-second letter\n")); // A MANIFEST whose S card does not parse (§3.6 wants `S filename hash`) is // outside shape two: shape two needs an S card that actually parses, not a // letter in column one. The artifact is invalid under §5.2 anyway, so a // conforming reader holds it as opaque content exactly as this does. try std.testing.expect(!repostate.transferGatedArtifact( "C t\nD 2026-07-27T12:00:00.000\nS a.txt not-a-hash\nU u\n", )); // Prose that STARTS with `S ` and continues into a line that is not a card // is still not a card run, so still not gated. try std.testing.expect(!repostate.transferGatedArtifact("S a.txt 0123\nnot a card\n")); try std.testing.expect(!repostate.transferGatedArtifact("F a.txt " ++ ("1" ** 64) ++ "\ntrailing junk\n")); // THE RESIDUAL, stated rather than hidden: a byte string that is a // COMPLETE, valid, D-less card run is type SUBMANIFEST under §4.3 no matter // what a human meant by it, so it is gated. §9.1 fixes the classification // ("MUST determine SUBMANIFEST through the reassignment rule of Section // 4.3"), and a pre-S reader rejects the same bytes as structural, so the // refusal costs that peer nothing it could have read. try std.testing.expect(repostate.transferGatedArtifact("S is the twenty-second letter\n")); } test "submanifests §8.1/§7.2: emission is off by default; the announcement turns on reader support" { // §8.1: "a project-scope boolean, default false". This is the layer that // keeps §8.2's provisional prohibition real — no zit mints an S-bearing // artifact unless an operator opts the repository in. try std.testing.expect(!submanifest_emission_default); try std.testing.expect(!clientMayEmitSCards(submanifest_emission_default, false)); // The operator opt-in carries its consequence in the text itself. try std.testing.expect(submanifest_enable_warning.len != 0); // The ANNOUNCEMENT is a different layer and a different input: §10.1's // version says what the implementation can read, and zit reads trees. try std.testing.expect(submanifest_reader_support); try std.testing.expectEqual(submanifest_min_peer_version, announcedVersion(22700, submanifest_reader_support)); // …which is exactly what lets two zit peers exchange a tree at all: the // gate reads the peer's announcement. try std.testing.expectEqual( GateDecision.emit, submanifestPeerVersionGate(announcedVersion(22700, submanifest_reader_support), true), ); // A peer with no sub-manifest reader announces what it always did, and the // function never lowers an already-higher announcement. try std.testing.expectEqual(@as(u32, 22700), announcedVersion(22700, false)); try std.testing.expectEqual(@as(u32, 99999), announcedVersion(99999, true)); } test "submanifests §7.1: a PUSHED tree manifest phantomizes its sub-manifests and the same reply gimmes them" { // §7.1 is a duty of STORING and is side-agnostic: the server stores a // pushed artifact exactly as a client stores a received one. Without this, // a push leaves the server holding a check-in it can never enumerate and // asking nobody for the rest. const gpa = std.testing.allocator; const sub_a = "1" ** 64; const sub_b = "2" ** 64; const manifest = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ sub_a ++ "\nS m.txt " ++ sub_b ++ "\nU u\n"; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, manifest, &name_buf); const request = try std.fmt.allocPrint( gpa, "pragma client-version 22700\npush 0000000000000000 2222222222222222\nfile {s} {d}\n{s}", .{ name, manifest.len, manifest }, ); defer gpa.free(request); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), request, &out); // §7.1 — a phantom for each missing reference, in the store the §3.2 walk // reads, so the reply asks the pusher for them. const rid_a = ms.store().lookup(sub_a) orelse return error.TestExpectedEqual; const rid_b = ms.store().lookup(sub_b) orelse return error.TestExpectedEqual; try std.testing.expect(!ms.store().held(rid_a)); try std.testing.expect(!ms.store().held(rid_b)); try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ sub_a ++ "\n") != null); try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ sub_b ++ "\n") != null); } test "submanifests §7.1: a pushed artifact predating this draft still ingests exactly as before" { // The backwards-compatibility half of the rule above: an artifact with no S // card mints nothing, so the reply is byte-identical to the pre-draft one. const gpa = std.testing.allocator; const flat = "C msg\nD 2026-07-27T12:00:00.000\nF a.txt " ++ ("1" ** 64) ++ "\nU u\n"; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, flat, &name_buf); const request = try std.fmt.allocPrint( gpa, "pragma client-version 22700\npush 0000000000000000 2222222222222222\nfile {s} {d}\n{s}", .{ name, flat.len, flat }, ); defer gpa.free(request); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const server = Server{ .version = .{ .version = 22800 }, .self_servercode = "1111111111111111", .project_code = "2222222222222222", }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ms.store(), request, &out); try std.testing.expectEqual(@as(repostate.Rid, 1), ms.store().count()); // only the pushed artifact try std.testing.expect(std.mem.indexOf(u8, reply, "gimme ") == null); } test "submanifests §7.1: storing an S-bearing manifest phantomizes every absent reference" { const gpa = std.testing.allocator; const sub_a = "1" ** 64; const sub_b = "2" ** 64; // A tree manifest: a C/D/S/S/U run. The Z card is irrelevant here — §7.1 // fires on what the receiver STORES, and the §6.2 verdict is the read // path's (and the persist hook's) job, not the transfer layer's. const manifest = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ sub_a ++ "\nS m.txt " ++ sub_b ++ "\nU u\n"; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, manifest, &name_buf); const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, manifest.len, manifest }); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); // §7.1 — "MUST create a phantom for each missing reference". const rid_a = ms.store().lookup(sub_a) orelse return error.TestExpectedEqual; const rid_b = ms.store().lookup(sub_b) orelse return error.TestExpectedEqual; try std.testing.expect(!ms.store().held(rid_a)); try std.testing.expect(!ms.store().held(rid_b)); // They are gimme-eligible, which is what makes §7.1's "a tree of depth d // converges in at most d+1 pull rounds" actually happen. try std.testing.expect(client.phantoms.contains(rid_a)); try std.testing.expect(client.phantoms.contains(rid_b)); try std.testing.expect(obs.phantom_created); } test "submanifests §7.3/§9.4: an absent S reference defers its dependent, and the arrival traverses the record" { // §7.3: "record the gap keyed by dependent and missing hash, idempotently, // and retry on arrival." §9.4: "a cascade that walks only the two older edge // kinds leaves such a check-in permanently unprocessed." // // The record is keyed by DEPENDENT — that is the whole point of §9.4, and // the thing a phantom set (keyed by missing hash) cannot express: which // check-in the gap actually breaks. const gpa = std.testing.allocator; const leaf_1 = "F a.txt " ++ ("1" ** 64) ++ "\n"; const leaf_2 = "F m.txt " ++ ("2" ** 64) ++ "\n"; var n1: [64]u8 = undefined; var n2: [64]u8 = undefined; const name_1 = artifact.artifactName(.sha3_256, leaf_1, &n1); const name_2 = artifact.artifactName(.sha3_256, leaf_2, &n2); const tree = try std.fmt.allocPrint( gpa, "C t\nD 2026-07-27T12:00:00.000\nS a.txt {s}\nS m.txt {s}\nU u\n", .{ name_1, name_2 }, ); defer gpa.free(tree); var nt: [64]u8 = undefined; const tree_name = artifact.artifactName(.sha3_256, tree, &nt); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(gpa); // ROUND 1 — the tree manifest arrives with NEITHER sub-manifest held. const body_t = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ tree_name, tree.len, tree }); defer gpa.free(body_t); _ = try client.consumeReply(gpa, ms.store(), body_t); // One DEPENDENT deferred, on two distinct gaps: §7.3's key shape exactly. try std.testing.expectEqual(@as(usize, 1), client.deferredTrees()); try std.testing.expectEqual(@as(usize, 2), client.tree_deferrals.?.gapCount()); try std.testing.expect(client.tree_deferrals.?.isDeferred(tree_name)); // ROUND 2 — one sub-manifest arrives. The dephantomization path traverses // the record: that gap retires, and the dependent STAYS deferred because // §5.2 forbids presenting a partially resolved tree as a file set. const body_1 = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name_1, leaf_1.len, leaf_1 }); defer gpa.free(body_1); _ = try client.consumeReply(gpa, ms.store(), body_1); try std.testing.expectEqual(@as(usize, 1), client.tree_deferrals.?.gapCount()); try std.testing.expectEqual(@as(usize, 1), client.deferredTrees()); try std.testing.expect(client.tree_deferrals.?.isDeferred(tree_name)); // ROUND 3 — the last one arrives; the dependent leaves the deferred set. // Without the §9.4 traversal it would sit there forever: this is the // "permanently unprocessed" check-in the section names. const body_2 = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name_2, leaf_2.len, leaf_2 }); defer gpa.free(body_2); _ = try client.consumeReply(gpa, ms.store(), body_2); try std.testing.expectEqual(@as(usize, 0), client.tree_deferrals.?.gapCount()); try std.testing.expectEqual(@as(usize, 0), client.deferredTrees()); try std.testing.expect(!client.tree_deferrals.?.isDeferred(tree_name)); } test "submanifests §7.3: a session that receives no S card records no deferral and allocates none" { // The backwards-compatibility half: the §9.4 record is opened lazily, so a // pull against a repository predating this draft never touches it and // `deferredTrees` answers 0 without a store lookup. const gpa = std.testing.allocator; const flat = "C t\nD 2026-07-27T12:00:00.000\nF a.txt " ++ ("3" ** 64) ++ "\nU u\n"; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, flat, &name_buf); const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, flat.len, flat }); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(client.tree_deferrals == null); try std.testing.expectEqual(@as(usize, 0), client.deferredTrees()); } test "submanifests §7.3: a tree whose sub-manifests are already held defers nothing" { const gpa = std.testing.allocator; const leaf = "F a.txt " ++ ("1" ** 64) ++ "\n"; var nl: [64]u8 = undefined; const leaf_name = artifact.artifactName(.sha3_256, leaf, &nl); const tree = try std.fmt.allocPrint( gpa, "C t\nD 2026-07-27T12:00:00.000\nS a.txt {s}\nU u\n", .{leaf_name}, ); defer gpa.free(tree); var nt: [64]u8 = undefined; const tree_name = artifact.artifactName(.sha3_256, tree, &nt); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); _ = try ms.store().put(leaf_name, leaf); // held BEFORE the tree arrives var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(gpa); const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ tree_name, tree.len, tree }); defer gpa.free(body); _ = try client.consumeReply(gpa, ms.store(), body); // Nothing is missing, so nothing is deferred — the gap record is about // ABSENCE, not about carrying an S card. try std.testing.expectEqual(@as(usize, 0), client.deferredTrees()); } test "submanifests §7.1: a flat manifest and a non-artifact blob phantomize nothing" { const gpa = std.testing.allocator; // Two negatives: an artifact with no S card at all, and a blob whose bytes // trip the pre-filter but do not tokenize as cards. Neither may mint a // phantom — the pre-filter is allowed false positives, the absorber is not. for ([_][]const u8{ "C t\nD 2026-07-27T12:00:00.000\nF a.txt " ++ "3" ** 64 ++ "\nU u\n", "some prose\nS is the twenty-second letter\n", }) |content| { var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, content, &name_buf); const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, content.len, content }); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), body); // Exactly one entry: the artifact itself. No reference was invented. try std.testing.expectEqual(@as(usize, 1), ms.store().count()); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); } } test "submanifests §7.1: a malformed S card voids the whole run before anything is applied" { const gpa = std.testing.allocator; // The first S card is well-formed, the second names a hash that fails the // §3.5 hash map. Validate-then-apply means NEITHER reference is minted — // an artifact that is not a legal tree manifest announces nothing. const manifest = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ "1" ** 64 ++ "\nS m.txt nothex\nU u\n"; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, manifest, &name_buf); const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, manifest.len, manifest }); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), body); try std.testing.expectEqual(@as(usize, 1), ms.store().count()); // the artifact itself, nothing else try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); }