Artifact 9237cc83d50336571ba4396d2e6fc000177d69b2960ffd57dbb2e399ff242ecb:
- File
src/sync.zig
— part of check-in
[2170688c47]
at
2026-08-22 21:07:10
on branch trunk
— config pull user: adopt a fossil peer's user table
The client can request /user and latch its rows, and 'zit config pull user --user=NAME --password=' applies them -- merging by default, --overwrite to replace, fossil's own two modes. Credentials are required in practice because fossil serves the area to an administrator alone; without them the peer sends nothing and the verb says why instead of reporting empty success. zit's own serve does not emit /user: it has no capability model to gate it with. Smoke S12 drives a real fossil server for both halves (#125). (user: dmestas, size: 519926) [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 cone_mod = @import("cone.zig"); const ignore_mod = @import("ignore.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 }; /// 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, /// #58 tolerate-and-report — unversioned files this session declined: /// legal, validated `uvfile`/`uvigot` cards zit has no unversioned store /// to honor. The CLI reads it after the run and reports the count, so a /// fossil peer's uv data is dropped VISIBLY, never silently. uv_skipped: u64 = 0, /// §3.2/§8.5 L1631-1633 — 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 L1605-1606/§3.2 L408 — 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, /// §3.2 L376-377/§9.1 L1853-1854 — 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, /// §6.5 (#74) — the x-f-l-c cookie value `buildRequest` minted for the /// REQUEST IT JUST BUILT, zero-length when that request's channel was not /// `.cookie`. Rebuilt every round: the nonce covers the round's own body, /// so yesterday's cookie is exactly the replayable credential §6.5 exists /// to prevent. The driver reads it through `loginCookie` after each /// `buildRequest` and hands it to the transport. cookie_buf: [max_line_len]u8 = undefined, cookie_len: usize = 0, /// §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 = .{}, /// §9.3 r28 — the peer's shun table, latched off its `config /shun` cards for /// the clone verb to adopt. Latch only, exactly like `peer_submanifest`: a /// pull must never silently re-shun on a peer's say-so, so nothing outside /// `runClone` reads this. /// /// Rows are COPIED, not borrowed: the reply body they were parsed from is a /// per-round buffer, and a clone's cleanup round outlives it. peer_shun: std.ArrayListUnmanaged(OwnedShunRow) = .empty, /// zit#125 — the peer's user rows as latched this session, copied for the /// same reason `peer_shun` copies: the reply body is a per-round buffer. peer_users: std.ArrayListUnmanaged(OwnedUserRow) = .empty, /// zit#125 — which configuration group an explicit `config pull` asks /// for. `/shun` is the default and the only one a CLONE ever requests; /// `/user` is reachable only through the operator-initiated verb, because /// the rows carry password hashes and fossil serves them to an /// administrator alone. config_group: []const u8 = config_shun_group, /// §9.3 r28 (#83c) — ask for the configuration groups on an ORDINARY round, /// not only a clone's cleanup round. Set by `zit config pull`, fossil's /// `configuration pull AREA`: an explicit, operator-initiated request. /// /// Deliberately opt-in and never automatic. Refreshing a shun table on /// every sync would let any peer suppress content here on its own say-so; /// making the operator ask is what keeps the takedown decision theirs. request_config: bool = false, /// §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, /// Sparse-cone RFC section 5.2 rule 3 -- answers "is this wire name an /// elided phantom under the store cone". Same total-function contract as /// `shun`; `.none` (every dense store) answers false for every name. elided: ShunSet = .none, /// Sparse-cone RFC section 6.1 -- the cone this client requests, RAW /// members borrowed from the operator's parse. Empty = dense request. /// Encoded at emission (`pragma cone MEMBER`), one card per member, on /// every round: the server is stateless across rounds (section 3.8). cone_members: []const []const u8 = &.{}, /// Section 6.1 exclusion members, emitted as `pragma cone-not MEMBER`. cone_not_members: []const []const u8 = &.{}, /// §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, /// The last peer error-card text, decoded, bounded -- kept so a refusal /// can be rendered in the peer's own words after the exchange aborts. peer_error_buf: [peer_error_cap]u8 = undefined, peer_error_len: usize = 0, /// §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. Legal-but-unsupported unversioned cards are /// no longer an error at all: they validate, count into `uv_skipped`, and /// the session continues (#58 tolerate-and-report). Auth failures never /// surface here: they arrive as `error` cards and route through /// `clientOnErrorCard`. pub const ConsumeError = error{ OutOfMemory, FatalProtocolError }; /// The peer's last error-card text, or null when none arrived. pub fn peerErrorText(self: *const Client) ?[]const u8 { if (self.peer_error_len == 0) return null; return self.peer_error_buf[0..self.peer_error_len]; } /// 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); for (self.peer_shun.items) |row| { gpa.free(row.hash); gpa.free(row.reason); } self.peer_shun.deinit(gpa); for (self.peer_users.items) |*row| freeOwnedUserRow(gpa, @constCast(row), row.owned.len); self.peer_users.deinit(gpa); if (self.tree_deferrals) |*td| td.deinit(); // submanifests §7.3/§9.4 self.* = undefined; } /// §9.3 r28 — the peer's shun rows as latched this session, for the clone /// verb to persist. Borrowed from the client; valid until `deinit`. pub fn peerShunRows(self: *const Client) []const OwnedShunRow { return self.peer_shun.items; } /// zit#125 — the peer's user rows as latched this session. Borrowed from /// the client; valid until `deinit`. pub fn peerUserRows(self: *const Client) []const OwnedUserRow { return self.peer_users.items; } /// Parse one `config /user` payload and keep an owned copy. A row that /// does not parse is dropped rather than aborting the session, exactly as /// a malformed shun row is: configuration is advisory payload (§9.3), and /// one unreadable row must not cost the operator the whole table. fn latchUserRow(self: *Client, gpa: std.mem.Allocator, payload: []const u8) error{OutOfMemory}!void { const q = parseUserConfigPayload(payload) catch return; const mtime = std.fmt.parseInt(i64, q.mtime_text, 10) catch return; var row: OwnedUserRow = .{ .login = "", .pw = "", .cap = "", .info = "", .mtime = mtime }; var filled: usize = 0; errdefer freeOwnedUserRow(gpa, &row, filled); for ([_]struct { q: ?[]const u8, out: *[]const u8 }{ .{ .q = q.quoted_login, .out = &row.login }, .{ .q = q.quoted_pw, .out = &row.pw }, .{ .q = q.quoted_cap, .out = &row.cap }, .{ .q = q.quoted_info, .out = &row.info }, }) |f| { const quoted = f.q orelse { filled += 1; continue; }; const buf = try gpa.alloc(u8, quoted.len); errdefer gpa.free(buf); const plain = fossilUnquote(quoted, buf) catch { gpa.free(buf); return; }; // `fossilUnquote` writes into `buf` and returns a prefix of it; // keep the whole allocation so the free below is exact. f.out.* = plain; row.owned[filled] = buf; filled += 1; } if (row.login.len == 0) { freeOwnedUserRow(gpa, &row, filled); return; // a row with no primary key names nobody } try self.peer_users.append(gpa, row); } /// Parse one `config /shun` payload and keep a copy. A row that does not /// parse, or whose name is not a plausible artifact name, is dropped: the /// only thing worse than missing a tombstone is writing one against the /// wrong artifact, which would suppress content nobody asked to suppress. fn latchShunRow( self: *Client, gpa: std.mem.Allocator, payload: []const u8, ) error{OutOfMemory}!void { const keyed = parseKeyedConfig(payload, "scom") catch return; const epoch = std.fmt.parseInt(i64, keyed.mtime_text, 10) catch return; var hash_buf: [64]u8 = undefined; if (keyed.quoted_name.len > 2 + hash_buf.len) return; const hash = fossilUnquote(keyed.quoted_name, &hash_buf) catch return; if (hash.len == 0 or !isLowerHexName(hash)) return; var reason_buf: [2 * shun_reason_max]u8 = undefined; if (keyed.quoted_value.len > 2 + reason_buf.len) return; const reason = fossilUnquote(keyed.quoted_value, &reason_buf) catch return; if (reason.len > shun_reason_max) return; const hash_copy = try gpa.dupe(u8, hash); errdefer gpa.free(hash_copy); const reason_copy = try gpa.dupe(u8, reason); errdefer gpa.free(reason_copy); try self.peer_shun.append(gpa, .{ .hash = hash_copy, .epoch = epoch, .reason = reason_copy }); } /// 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 zit's non-normative (§4 L830-833) /// 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); // §9.1 L1853-1854 / §3.2 r22 L376-377 — 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); // Sparse-cone RFC section 6.1 -- one F-card-encoded member per card. // Stock fossil ignores unknown pragmas by contract, which IS the // section 6.3 degradation; a cone-aware server filters its reply. for (self.cone_members) |member| { var enc_buf: [2 * cone_mod.max_member_len]u8 = undefined; const encoded = artifact.encodeEscapes(member, &enc_buf); const card = std.fmt.bufPrint(line_buf, "pragma cone {s}", .{encoded}) catch return error.RequestTooLarge; try appendCard(gpa, out, card); } for (self.cone_not_members) |member| { var enc_buf: [2 * cone_mod.max_member_len]u8 = undefined; const encoded = artifact.encodeEscapes(member, &enc_buf); const card = std.fmt.bufPrint(line_buf, "pragma cone-not {s}", .{encoded}) catch return error.RequestTooLarge; try appendCard(gpa, out, card); } // §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; const channel = loginTransport(identity, self.peer, self.operation, round); self.cookie_len = 0; switch (channel) { .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); }, .none, .cookie => { try self.appendBody(gpa, store, out, line_buf); }, } if (out.items.len > reply_body_cap) return error.RequestTooLarge; // §6.5 — the cookie channel signs EVERY request: the nonce covers the // whole decompressed body (there is no login line to exclude), and a // fresh credential is minted per round. #74 was exactly this credential // going missing on rounds 2+ — the peer's gate flips true once round // 1's reply records its version, the body login correctly stops, and // without a per-round cookie the authenticated exchange went anonymous // for the rounds that carry the artifacts. if (channel == .cookie) { const cred = self.credential.?; const nonce = computeNonce(out.items); const signature = computeSignature(&nonce, &cred.secret_hex); const value = writeCookieLogin( .{ .user = cred.user, .nonce = &nonce, .signature = &signature }, &self.cookie_buf, ) orelse return error.RequestTooLarge; self.cookie_len = value.len; } std.debug.assert((self.cookie_len != 0) == (channel == .cookie)); std.debug.assert(out.items.len >= pragma.len + 1); // the pragma line is always present return out.items; } /// §6.5 (#74) — the cookie credential for the most recently built /// request, or null. Valid until the next `buildRequest`. pub fn loginCookie(self: *const Client) ?[]const u8 { if (self.cookie_len == 0) return null; return self.cookie_buf[0..self.cookie_len]; } /// §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); // §9.3 r28 — and the peer's shun table. A separate card // rather than widening the one above: `/config` is the // SCALAR marker (r24), not a group name, so the two cannot // be expressed as one request. A peer that models no shun // group answers nothing and no error, which is what every // peer predating this did. const shun_card = writeReqconfig(config_shun_group, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, shun_card); }, .stop => unreachable, // a request is composed only while §3.7 continues }, } // §9.3 r28 (#83c) — an explicit `zit config pull` asks on whatever round // it is running, since it has no clone cleanup round to defer to. if (self.request_config and self.operation != .clone) { const card = writeReqconfig(self.config_group, line_buf) catch return error.RequestTooLarge; try appendCard(gpa, buf, card); // ONE-SHOT. §3.2 makes an exchange multi-round, and re-asking every // round would draw the same table again per round -- wasted bytes, // and a duplicate-laden latch the caller then has to de-duplicate. self.request_config = false; } 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, .elided = self.elided.contains(store.hashOf(rid)), })) { 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); } } /// §8.5 L1631-1633 — 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) { // Sparse-cone RFC section 5.3 -- a round-0 reply that carried an // error card and NO clone progress (no artifact, no clone-seqno // advance) is a refusal. The section 11.1 discard rule exists for // cosmetic error cards riding an otherwise-progressing reply; // treating a progress-free one as fatal is what keeps a refused // clone from "succeeding" empty (silent incompleteness). if (obs.error_card_discarded and !obs.artifact_received_this_round and !obs.clone_seqno_received) { return self.abort(); } // §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 L1605-1606/§3.2 L408 — 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; obs.clone_seqno_received = true; 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. Either // way the decoded text is kept (bounded) so the CLI can render // a refusal in the peer's own words. if (tokens.len >= 2) { const encoded = tokens.items[1]; var scratch: [peer_error_cap]u8 = undefined; const take = @min(encoded.len, scratch.len); const decoded = artifact.decodeEscapes(encoded[0..take], &scratch); @memcpy(self.peer_error_buf[0..decoded.len], decoded); self.peer_error_len = decoded.len; } return switch (clientOnErrorCard(self.state)) { .discard => blk: { obs.error_card_discarded = true; break :blk payload_start; }, .terminate => self.abort(), }; }, .message => { // §10.2 L2118 — "Exactly two visible tokens are required." // 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 L1888/L1889-1891 — `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; } else if (std.mem.eql(u8, cc.groupname, config_shun_group)) { // A row that does not parse is SKIPPED, not fatal: §9.3 // treats configuration as advisory payload, and aborting a // whole clone over one malformed shun row would trade a // missing tombstone for no repository at all. The skip is // the same posture the scalar group takes per row. self.latchShunRow(gpa, p.bytes) catch return self.abort(); } else if (std.mem.eql(u8, cc.groupname, config_user_group)) { // Same advisory posture as the shun group: a row that does // not parse is skipped, never fatal. self.latchUserRow(gpa, p.bytes) catch return self.abort(); } 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 §B L2863 // `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(); } // #58 tolerate-and-report: the card was legal and its content // verified, and zit has no unversioned store to put it in. // Counted so the session can SAY what it declined; never an // abort — a peer holding uv data must still sync everything // versioned. self.uv_skipped += 1; return payload.next; // past the framed uv content, at the next card }, .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(); // #58 — an announcement of a file this side will never fetch: // one uv file not transferred, same count as an unsolicited // uvfile above. self.uv_skipped += 1; return payload_start; // announcement card, no byte payload }, .uvgimme => { // #58 — the peer asking US for a uv file: nothing is lost on // this side (zit holds none), so validated and ignored, not // counted. _ = parseUvgimmeCard(tokens) catch return self.abort(); return payload_start; // request card, no byte payload }, } } /// §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 §7.7 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.1 L262-264 — 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 { /// §9.1 L1854-1857 — 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 L1517-1519/§8.5 L1631-1633 — 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, /// Sparse-cone RFC section 6.2 -- the embedder's cone classifier; `.none` /// serves every cone request densely (section 6.3 degradation, from the /// server's own side). cone_classifier: ConeClassifier = .none, /// #88 — this server's own content policy for pushes. `.none` accepts /// everything, which is what every server that declared nothing does. push_policy: PushPolicy = .none, /// Sparse-cone RFC section 6.2/5.3 -- this store's OWN cone when it is /// sparse; null for a dense store. Arms the section 5.3 clone-origin /// refusal: a clone request whose cone this store does not wholly cover /// (or a dense clone request) is answered with one error card. origin_cone: ?*const cone_mod.Cone = null, /// The per-reply clone byte budget (see `clone_cfile_batch_bytes`). A /// server tuning knob, never wire-visible; tests shrink it to page a /// repository across byte-bound batches. clone_batch_bytes: usize = clone_cfile_batch_bytes, /// 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. /// §9.3 r28 — this repository's shun table, answered when a request selects /// the `/shun` group (fossil's `configure_send_group`). Carried as a plain /// field, like every other repository-scope fact this struct holds, rather /// than reached through `ArtifactStore`: the store vtable is the ARTIFACT /// interface, and shun rows are a table about artifacts, not artifacts. /// /// Empty by default, which is also the answer for a repository that shuns /// nothing — so a server that never populates it behaves exactly as it did /// before the group existed. shun_rows: []const ShunRow = &.{}, 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 zit's local cap (§4 L830-833 sets none). 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, null, out)).body; } /// `respond` for a transport that carried an `x-f-l-c` cookie header — /// the §6.5 credential channel a ≥22700 client uses on every round. pub fn respondCookie( self: *const Server, gpa: std.mem.Allocator, store: repostate.ArtifactStore, request: []const u8, cookie: ?[]const u8, out: *std.ArrayListUnmanaged(u8), ) RespondError![]const u8 { return (try self.respondWithMedia(gpa, store, request, cookie, 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, cookie: ?[]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, cookie)) |fault| return .{ .body = try emitError(gpa, out, line_buf, fault), .media = .x_fossil }; // §9.3 r31 L1927-1929 — 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| { // Sparse-cone RFC section 5.3 -- "a server SHOULD refuse to act // as a clone origin for cones it does not cover". Checked before // any reply byte: refusal is one error card, never a partial // clone that heals into silent incompleteness elsewhere. if (self.origin_cone) |origin| { if (!cloneConeAdmissible(origin, scan.cone_members.items)) { return .{ .body = try emitError(gpa, out, line_buf, .sparse_origin), .media = .x_fossil }; } } 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 }; } // #88 — decided BEFORE ingest, so a refused push stores nothing. A push // is a batch, and accepting the conforming half would leave the client's // DAG naming artifacts this server declined — phantoms manufactured by // policy. The whole round is refused instead. var policy_buf: [peer_error_cap]u8 = undefined; if (try pushPolicyFault(gpa, self.push_policy, &scan, &policy_buf)) |text| { return .{ .body = try emitError(gpa, out, line_buf, .{ .content_policy = text }), .media = .x_fossil }; } 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); } /// §9.3 r28 — answer the TABLE groups a request selected. Today that is /// `/shun`, the group whose absence is a SAFETY gap rather than a cosmetic /// one: fossil's shun table is its takedown mechanism, so a peer that /// clones a repository without it will, on its next sync with any third /// party, re-accept exactly the content an operator was ordered to remove. /// /// One card per row, fossil's `configure_send_group` shape, because /// `configure_receive` reads the first token pair of a payload as /// `(mtime, primary-key)` and every later pair as `(field, value)`. /// /// Read permission gates this exactly as it gates the scalar group (r21); /// a request that selected nothing emits nothing and no error, which is /// what every request did before this group existed. fn appendGroupConfig( self: *const Server, gpa: std.mem.Allocator, scan: *const RequestScan, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, ) RespondError!void { if (scan.reqconfig_groups.count() == 0) return; if (serverReqconfigDisposition(server_grants_read) == .ignore) return; if (!scan.reqconfig_groups.contains(.shun)) return; var payload_buf: [shun_config_payload_max]u8 = undefined; for (self.shun_rows) |row| { // A row this server cannot spell on the wire is SKIPPED, never // truncated: a half-written name would name a different artifact, // and un-shunning by typo is the one outcome worse than not // carrying the row at all. Storage bounds both fields, so this is // unreachable in practice and stated rather than asserted. if (row.hash.len > 64 or row.reason.len > shun_reason_max) continue; const payload = writeShunConfigPayload(row, &payload_buf) catch return error.ResponseTooLarge; const card = writeConfigCard(config_shun_group, @intCast(payload.len), line_buf) catch return error.ResponseTooLarge; try appendCard(gpa, out, card); 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 { // §9.1 L1854-1859 — 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); try self.appendGroupConfig(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/§8.6 L1753-1755 — 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. // Sparse-cone RFC section 6.2, the pull half: a cone-scoped requester // is not told about out-of-cone holdings, so it never mints phantoms // for them and never gimmes them -- the transfer narrows at its // source. The server's own gimme-begging arm below stays unfiltered: // its phantoms are its own holdings policy, not the requester's. const advertise_keep: ?*const std.DynamicBitSetUnmanaged = if (scan.cone_members.items.len != 0) self.cone_classifier.vtable.classify(self.cone_classifier.context, scan.cone_members.items, scan.cone_not_members.items) else null; 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 (advertise_keep) |k| { if (rid < k.capacity() and !k.isSet(rid)) continue; } 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 L1504-1505/L1517-1520 — 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 (§3.7 L609-610 liveness) std.debug.assert(self.clone_batch_bytes >= 1); // ditto: the first card always lands, so the cursor advances // §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; // Sparse-cone RFC section 6.2: a request carrying cone pragmas is // answered from the keep classification when the embedder provides // one; otherwise densely (section 6.3 -- degradation, never error). // The SEQNO cursor advances over skipped rids exactly as over sent // ones, so a cone-scoped clone still converges in bounded rounds. const cone_keep: ?*const std.DynamicBitSetUnmanaged = if (scan.cone_members.items.len != 0) self.cone_classifier.vtable.classify(self.cone_classifier.context, scan.cone_members.items, scan.cone_not_members.items) else null; const n: usize = store.count(); const clone_body_start = out.items.len; // only cfile bytes count against the budget var rid: usize = start; var emitted: usize = 0; var batch_full = false; while (rid <= n) : (rid += 1) { // Batch full — by bytes (the practical limit) or count (the DoS // backstop): resume at `rid`. if (out.items.len - clone_body_start >= self.clone_batch_bytes or emitted >= self.clone_batch_max) { batch_full = true; break; } if (cone_keep) |k| { if (rid < k.capacity() and !k.isSet(rid)) continue; // elided server-side (RFC 6.2) } 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 §3.7 L606-610 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) == batch_full); std.debug.assert(!batch_full or emitted >= 1); // a full batch carried at least one card (liveness) 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`). A /// DoS backstop only: the BYTE budget below is the practical limiter (a /// count-bound batch made every clone round carry ~1MB — 0.2% of the byte /// ceiling — so an N-artifact clone paid ~N/1024 serial round trips and the /// transfer was latency-bound instead of throughput-bound). 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 = 65536; /// The default per-reply BYTE budget a clone batch targets — the real batch /// limiter (`Server.clone_batch_bytes` overrides it). Large enough that /// rounds are throughput-bound, small enough that a slow peer's round /// completes well inside serve's per-connection deadline (the issue-#22 /// failure mode — a healthy-but-slow answer cancelled mid-flight — is what /// an unbounded reply would re-approach). The budget is a TARGET, not a /// ceiling: the card that crosses it still lands whole, so one oversized /// artifact overshoots by its own size — exactly the exposure the old count /// cap had — and `reply_body_cap` remains the hard error. Never /// wire-visible: §8.2 L1505-1506 forbids clients inferring count or size. const clone_cfile_batch_bytes: usize = 4 << 20; /// §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 L1927-1929 — a pushed config card without Admin sparse_origin, // sparse-cone RFC §5.3 — a clone this sparse origin does not cover /// #88 — a pushed check-in introduces content this server's declared /// policy refuses. The payload is the refusal text, already assembled by /// the classifier because only it knows the path and the rule. content_policy: []const u8, }; /// §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"; /// Sparse-cone RFC section 5.3 -- the impl-chosen refusal TEXT for a clone /// request this sparse origin does not cover. The SHOULD-refuse behaviour is /// the spec's; the wording is ours, and it names the remedy. const sparse_origin_refusal_text = "sparse origin does not cover the requested clone cone; clone from a dense peer or request a covered cone"; /// #88 — the server's own content policy for pushes, supplied by the embedder /// exactly as `cone_classifier` is. `.none` (the default, both globs empty) /// accepts every push, so a server that declared nothing behaves as before. /// /// The policy is deliberately the SERVER's, never read from the artifacts being /// pushed: a push that carried its own `.zit-settings` would otherwise arrive /// bearing permission to land, which is the same reasoning that keeps /// `before-commit` consent local (#82). /// /// Only INSPECTING rules live here. A server must never execute a command that /// a peer's content chose, so `before-commit` has no server-side counterpart — /// the #81 division (inspect binds everyone, execute binds only where accepted) /// carries across the wire unchanged. pub const PushPolicy = struct { binary_glob: []const u8 = "", crlf_glob: []const u8 = "", pub const none: PushPolicy = .{}; /// Whether this server has an opinion at all. Undeclared is not /// "refuse everything" — it is the state of every server that never asked. pub fn declared(self: PushPolicy) bool { return self.binary_glob.len != 0 or self.crlf_glob.len != 0; } /// The rule `path` violates, or null. Mirrors the commit-side classifier /// (#81): a class is checked only when its glob is set, and binary is /// tested first because a binary file usually also carries CR/LF. pub fn violation(self: PushPolicy, path: []const u8, content: []const u8) ?[]const u8 { if (self.binary_glob.len != 0 and std.mem.indexOfScalar(u8, content, 0) != null and !ignore_mod.listMatches(self.binary_glob, path)) return "binary-glob"; if (self.crlf_glob.len != 0 and std.mem.indexOf(u8, content, "\r\n") != null and !ignore_mod.listMatches(self.crlf_glob, path)) return "crlf-glob"; return null; } }; /// Bound on the peer error-card text a client copies out for diagnostics. pub const peer_error_cap: usize = 256; /// §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, /// Sparse-cone RFC section 6.1 -- the request's cone, one F-card-encoded /// member per `pragma cone MEMBER` card, borrowing the request buffer. /// Bounded at the cone member cap; the 65th is ignored rather than /// faulted (pragmas are requests only). cone_members: std.ArrayListUnmanaged([]const u8) = .empty, /// Section 6.1 exclusion members (`pragma cone-not MEMBER`), same /// encoding and bounds discipline as `cone_members`. cone_not_members: std.ArrayListUnmanaged([]const u8) = .empty, 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 r28 — the TABLE groups this request selected, as a mask: fossil's /// `/all` names several at once, so several reqconfig cards in one request /// union rather than overwrite. reqconfig_groups: ConfigGroupMask = ConfigGroupMask.initEmpty(), /// §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.cone_members.deinit(gpa); self.cone_not_members.deinit(gpa); 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; } else if (cc.tokens.len == 3 and parsePragmaName(cc.tokens.items[1]) == .cone) { // Sparse-cone RFC section 6.1. Requests only: over-cap or // malformed members are ignored, never faulted. if (scan.cone_members.items.len < cone_mod.max_members) { try scan.cone_members.append(gpa, cc.tokens.items[2]); } } else if (cc.tokens.len == 3 and parsePragmaName(cc.tokens.items[1]) == .@"cone-not") { // The joint cap covers both polarities: total members bound // the request's cone exactly as Cone.init will. if (scan.cone_members.items.len + scan.cone_not_members.items.len < cone_mod.max_members) { try scan.cone_not_members.append(gpa, cc.tokens.items[2]); } } }, .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 §7.7 L1445-1450 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 §8.1 L1498-1500'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; } else { // r28 — a TABLE group. Unmodelled names resolve to an empty // mask, which stays the §9.3 L1884-1886 silent no-op it always // was; `/shun` and `/all` now resolve to something. scan.reqconfig_groups.setUnion(configGroupMask(name)); } }, .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, cookie: ?[]const u8) ?RespondFault { if (duplicateLogin(cookie != null, scan.login_count)) return .unauthorized; // §6.5 (#74) — the cookie channel: an `x-f-l-c` value the transport // carried is a login whose nonce covers the WHOLE request body (there is // no login line to exclude). A malformed cookie is `.unauthorized`, not // ignored — a credential that fails to parse must not fall through to // anonymous. if (cookie) |value| { var user_buf: [max_line_len]u8 = undefined; if (value.len >= user_buf.len) return .unauthorized; const card = parseCookieLogin(value, &user_buf) orelse return .unauthorized; const cred = credential orelse return null; if (!std.mem.eql(u8, card.user, cred.user)) return .unauthorized; const nonce = computeNonce(request); if (!std.mem.eql(u8, &nonce, card.nonce)) return .unauthorized; const signature = computeSignature(&nonce, &cred.secret_hex); if (!std.mem.eql(u8, &signature, card.signature)) return .unauthorized; return null; } 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; } /// §9.1 r10 L1855-1857 — 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. /// #88 — the refusal text for a push the server's policy declines. Assembled /// here because only this point knows both the path and the rule; the client /// copies it out of the error card verbatim (`peer_error_cap`), so it has to /// name what to fix without any further round trip. fn pushPolicyText(buf: []u8, path: []const u8, rule: []const u8) []const u8 { return std.fmt.bufPrint( buf, "'{s}' violates this server's {s}", .{ path, rule }, ) catch "a pushed file violates this server's content policy"; } /// #88 — whether any check-in in this push introduces content the server's /// policy refuses, and if so the text saying which. /// /// A push sends artifacts BY HASH: a blob does not know its own path, and only /// a manifest's F cards supply one. So the walk is manifest-first — for each /// pushed manifest, resolve each F card's blob out of this same push and /// classify it against the path the card gives it. /// /// That choice makes the policy gate what becomes REACHABLE rather than what /// bytes transit, which is the honest line for a content-addressed store: a /// blob no retained manifest names is not part of history. A blob whose /// manifest is not in this push therefore goes unclassified — it is also /// unreferenced, and `purge`/`shun` reclaim it. Refusing the round is what /// stops the check-in that would have made it reachable. /// /// Bounded by the push itself: no store reads, no allocation beyond the /// manifest parse the walk already needs. fn pushPolicyFault( gpa: std.mem.Allocator, policy: PushPolicy, scan: *const RequestScan, buf: []u8, ) Server.RespondError!?[]const u8 { if (!policy.declared()) return null; for (scan.files.items) |candidate| { // Only a manifest carries paths. A blob that fails to parse as one is // simply not a manifest — never an error here, since this walk is a // policy question and admission is decided elsewhere. var loaded = artifact.parseForImport(gpa, candidate.payload) catch continue; defer loaded.deinit(gpa); if (loaded.manifest.kind != .manifest) continue; for (loaded.manifest.f) |fc| { const hash = fc.hash orelse continue; // a delete marker names no blob for (scan.files.items) |blob| { if (!std.mem.eql(u8, blob.hash, hash)) continue; if (policy.violation(fc.filename, blob.payload)) |rule| { return pushPolicyText(buf, fc.filename, rule); } break; } } } return null; } 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 L1928-1929 — 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), .sparse_origin => writeSignalCard(.err, sparse_origin_refusal_text, line_buf), // #88 — the text is assembled by the classifier, which is the only // place that knows the path and the rule; §11.1 r2's free-text error // card is the carrier, as it is for the auth and sparse refusals. .content_policy => |text| writeSignalCard(.err, 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 // --------------------------------------------------------------------------- /// §9 L1802-1806 — the draft's pragma catalog is FIFTEEN names: nine client- /// to-server (send-private, send-catalog, client-version, uv-hash, ci-lock, /// ci-unlock, client-url, req-links, req-clusters) and six server-to-client /// (server-version, uv-push-ok, uv-pull-only, ci-lock-fail, avoid-delta- /// manifests, and link), in the draft's own listing order. /// /// ZIT-LOCAL OMISSION — `link`. §9.5 L1998-2005 makes `pragma link URL ARG /// MTIME` response-local to req-links and gates client acceptance on "local /// share-links mode", directing a client without it to "discard all fields". /// zit implements no share-links mode, so every link card must be discarded. /// §9 L1799-1801 requires unrecognized pragma names be silently ignored, and /// leaving `link` out of this enum IS that discard: `parsePragmaName` returns /// null and the card takes the ignore path. Modelling it would buy a handler /// with nothing to do. So: fourteen of the draft's fifteen names, followed by /// server names followed by five server-to-client names, in the RFC's /// own listing order. pub const PragmaName = enum { /// Sparse-cone RFC section 6.1 -- `pragma cone MEMBER`, one per member, /// F-card-encoded. Riding the pragma mechanism is what makes stock /// fossil's silent-ignore contract the degradation path of section 6.3. cone, /// Sparse-cone RFC section 6.1 -- `pragma cone-not MEMBER`, an exclusion /// member. A distinct name, not a marker byte: every byte that could /// mark a member negative is a legal filename byte. A server knowing /// cone but not cone-not over-sends; the requester's local filter /// absorbs it, so partial understanding degrades like none (§3.2). @"cone-not", @"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 { // §9 L1802-1806 enumerates fifteen sync pragma names (nine client-to- // server, six server-to-client). `PragmaName` models the fourteen zit // acts on — all but `link`, per the ZIT-LOCAL OMISSION note on the enum — // plus the one draft-fossil-submanifests-00 §7.2 adds. // draft-fossil-submanifests-00 §7.2 adds. std.debug.assert(@typeInfo(PragmaName).@"enum".fields.len == 14 + 1 + 2); // +2: sparse-cone RFC 6.1 cone / cone-not pragmas } /// §9 L1802-1806 — which side originates a pragma. pub const PragmaDirection = enum { client_to_server, server_to_client }; /// §9 L1802-1806 — direction table, split exactly as the RFC lists it. pub fn pragmaDirection(name: PragmaName) PragmaDirection { return switch (name) { .cone, // sparse-cone RFC 6.1: the requester scopes its own transfer .@"cone-not", .@"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 { // §9 L1802-1806 — the draft splits its fifteen names nine client-to-server // and six server-to-client. zit models all nine client-to-server names and // five of the six server-to-client (it omits `link`; see the ZIT-LOCAL // OMISSION note on `PragmaName`). submanifests §7.2 adds one more server- // to-client name, avoid-submanifests — hence 5 + 1 below. // 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 + 2 and s2c == 5 + 1); // +2: cone, cone-not (sparse-cone RFC 6.1) } /// §9 r2/r3/r5 L1799-1802 — 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 }; /// §9 r1 L1799 — 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 }; } /// §9.1 L1853-1854 — 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, /// §9.1 r10 L1855-1857 — server's peer record before any /// client-version pragma has been received. pub fn serverInit() Peer { return .{ .version = 0 }; } /// §9.1 r14 L1860-1862 — 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 }; } }; /// §9.1 r9 L1854-1857 (server records client) and r13 L1857-1862 /// (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)); } /// §3.2 r6 L376-377 — 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}; /// §9 r11 L1819-1821 — "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; /// §2 r7 L249-250 — 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; } /// §9.1 r6/r8/r11 L1853-1859 — 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; } /// §9.1 r11/r12 L1854-1859 — 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; } /// §9.1 r6/r8 L1853-1859 — 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}); } /// §9.1 r11 L1854-1859 — 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 }); } /// §8.5.1 r15 L1641 — `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); } /// §9.2 r16 L1868-1869, §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); } /// §9.2 r17/r18 L1869-1872 — the server's per-request unversioned-file /// response. pub const UvResponse = enum { none, push_ok, pull_only }; /// §9.2 r17/r18 L1869-1872, §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, }; /// §9.2 L1872-1874 — pragma uv-push-ok enables client upload; /// uv-pull-only disables it destructively (status-4/5 removal), so in zit /// 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 (uv-pull-only disables upload) — in zit that /// 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 9.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 r28 — the TABLE configuration groups a `reqconfig NAME` can select, /// as a set rather than a single name: fossil's `/all` selects every group at /// once, so a request resolves to a MASK, not to one group. /// /// zit models the groups whose data it actually holds. `shun` is the one that /// matters to a version-control core rather than a web UI: fossil's shun table /// is its takedown mechanism, and a peer that clones without it will re-accept /// on its next sync exactly the content an operator was ordered to remove. The /// rest of fossil's groups (`/skin`, `/css`, `/ticket`, `/user`, `/email`, /// `/alias`, `/subscriber`, `/interwiki`) describe a web interface and a user /// table zit has no model for; they resolve to an empty mask, which §9.3 /// L1884-1886 makes a silent no-op, exactly as an unmodelled group was before. pub const ConfigGroup = enum { shun }; pub const ConfigGroupMask = std.enums.EnumSet(ConfigGroup); /// fossil's `configure_name_to_mask` (src/configure.c), including its PREFIX /// matching: the request's name is compared against the first `name.len` bytes /// of each known group, so `/sh` selects `/shun` and `/a` selects `/all`. The /// leading slash is stripped before comparison there; it is required here /// (r20) and skipped the same way. /// /// Faithfulness to the prefix rule is not pedantry — fossil's own `configure /// pull shun` passes an abbreviated name, so a server that demanded the full /// spelling would answer nothing and look like a server with no shun list. pub fn configGroupMask(name: []const u8) ConfigGroupMask { std.debug.assert(configNameHasSlashPrefix(name)); const bare = name[1..]; if (bare.len == 0) return .initEmpty(); // `/all` selects everything zit models; every other entry selects itself. // Ordered as fossil orders `aGroupName`, so a prefix ambiguous between two // entries resolves to the same one fossil resolves it to. const table = [_]struct { name: []const u8, groups: []const ConfigGroup }{ .{ .name = "shun", .groups = &.{.shun} }, .{ .name = "all", .groups = &.{.shun} }, }; for (table) |entry| { if (bare.len > entry.name.len) continue; if (!std.mem.eql(u8, bare, entry.name[0..bare.len])) continue; var mask: ConfigGroupMask = .initEmpty(); for (entry.groups) |g| mask.insert(g); return mask; } return .initEmpty(); } /// §9.3 r20 L1882 — the reqconfig NAME MUST-begin-with-/ gate. pub fn configNameHasSlashPrefix(name: []const u8) bool { return name.len >= 1 and name[0] == '/'; } /// The `/shun` group's GROUPNAME on the wire (fossil `configure_send_group`). pub const config_shun_group: []const u8 = "/shun"; /// zit#125 — a user row whose four strings this client owns, latched from a /// peer's `config /user` card and freed in `Client.deinit`. `owned` holds the /// four backing allocations: `fossilUnquote` returns a PREFIX of the buffer /// it wrote into, so freeing the visible slice would free the wrong length. pub const OwnedUserRow = struct { login: []const u8, pw: []const u8, cap: []const u8, info: []const u8, mtime: i64, owned: [4]?[]u8 = .{ null, null, null, null }, }; fn freeOwnedUserRow(gpa: std.mem.Allocator, row: *OwnedUserRow, filled: usize) void { for (row.owned[0..@min(filled, row.owned.len)]) |maybe| { if (maybe) |b| gpa.free(b); } } /// A shun row whose two strings this client owns, latched from a peer's /// `config /shun` card and freed in `Client.deinit`. pub const OwnedShunRow = struct { hash: []const u8, epoch: i64, reason: []const u8, }; /// An artifact name as the shun table carries it: lowercase hex, 40 or 64 /// bytes (SHA-1 or SHA3-256). Deliberately stricter than "any hex": a shun row /// names an artifact, and accepting a name no artifact could have would let a /// malformed peer plant tombstones that match nothing and confuse `verify`. fn isLowerHexName(s: []const u8) bool { if (s.len != 40 and s.len != 64) return false; for (s) |c| switch (c) { '0'...'9', 'a'...'f' => {}, else => return false, }; return true; } /// One shun-table row as this protocol carries it. Mirrors fossil's `shun` /// table columns exactly — `(mtime, uuid, scom)` — because the payload IS /// those three values and a receiving fossil inserts them straight back. pub const ShunRow = struct { /// The shunned artifact's name, lowercase hex (fossil's `uuid`). hash: []const u8, /// Unix seconds the shun was recorded (fossil's `mtime`). epoch: i64, /// Free operator text (fossil's `scom`, the "shun comment"). May be empty. reason: []const u8, }; /// Payload ceiling for one `/shun` row: a signed decimal mtime, a /// SP-and-quoted 64-hex name, the literal ` scom `, and a quoted reason whose /// every byte may double under `fossilQuote`. /// Matches `logstore.max_shun_reason`, restated rather than imported: this /// module is the wire and deliberately does not depend on the storage layer. /// `writeShunConfigPayload` refuses a longer reason instead of trusting it. pub const shun_reason_max: usize = 200; pub const shun_config_payload_max: usize = 20 + 1 + (2 + 2 * 64) + " scom ".len + (2 + 2 * shun_reason_max); /// One `/shun` row's payload, fossil's `configure_send_group` format byte for /// byte: `MTIME 'UUID' scom 'SCOM'`, with `fossilQuote` standing in for /// SQLite's `quote()`. A receiving fossil parses it with `configure_receive`'s /// `@shun` type — primary key `uuid`, one field `scom` — so the spelling of /// the field name is load-bearing, not decorative. /// /// One row per card, never several packed into one: `configure_receive` reads /// the first token pair as `(mtime, primary-key)` and every later pair as /// `(field, value)`, so a second row inside one payload would be read as /// nonsense field names and silently dropped. pub fn writeShunConfigPayload(row: ShunRow, out: []u8) std.fmt.BufPrintError![]u8 { if (out.len < shun_config_payload_max) return error.NoSpaceLeft; var i: usize = 0; const mtime = try std.fmt.bufPrint(out[i..], "{d}", .{row.epoch}); i += mtime.len; out[i] = ' '; i += 1; const uuid = fossilQuote(row.hash, out[i .. i + 2 + 2 * row.hash.len]); i += uuid.len; const literal = " scom "; @memcpy(out[i .. i + literal.len], literal); i += literal.len; const scom = fossilQuote(row.reason, out[i .. i + 2 + 2 * row.reason.len]); i += scom.len; return out[0..i]; } /// §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 L1882-1883 — 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 L1888-1889 — a parsed `config GROUPNAME SIZE` card header. pub const ConfigCard = struct { groupname: []const u8, size: u32 }; /// §9.3 L1923-1927 — the draft gives a negative SIZE no distinct outcome: a /// signed SIZE and a non-decimal SIZE alike never enter the configuration /// handler and take the unknown-card path. The split tag is zit-local and /// diagnostic only: both call sites collapse `Malformed` and `NegativeSize` /// into the same reject, and sync_test.zig's "a malformed config header is a /// fatal abort" pins all four header faults, negative SIZE included. 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 L1888-1891 — 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 L1909-1910 — 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 L1909-1910 — 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 L1893-1896 — 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; } /// The `/user` group's GROUPNAME on the wire (#124). pub const config_user_group: []const u8 = "/user"; /// One user-table row as this protocol carries it, values still WIRE-QUOTED. /// Quoted like `KeyedConfig` and for the same reason: unquoting is the /// caller's explicit `fossilUnquote` step, so this parse stays /// allocation-free. A field fossil omitted (its column was NULL) is null /// here — distinct from a field it sent as the empty string `''`. pub const QuotedUserRow = struct { mtime_text: []const u8, quoted_login: []const u8, quoted_pw: ?[]const u8 = null, quoted_cap: ?[]const u8 = null, quoted_info: ?[]const u8 = null, }; /// fossil `configure.c`'s `{ "@user", "login", 5, {"pw","cap","info","photo","jx"} }` /// payload: `MTIME 'LOGIN'` then zero or more `FIELD VALUE` pairs, in any /// order, with any subset present — `configure_send_group` omits every column /// whose `quote()` is NULL, so a row with no password simply has no `pw` pair. /// /// TOKENIZATION IS FOSSIL'S, and it is not "everything is quoted". Values are /// read by `blob_sqltoken` (fossil `blob.c:871`): a token starting with `'` is /// a quoted string with doubled-quote escaping; ANYTHING ELSE RUNS TO THE NEXT /// WHITESPACE. That second branch is load-bearing rather than defensive — /// `photo` is a BLOB, so `quote()` renders it as SQLite's `X'89504e47…'` /// literal, which does not start with a quote and which `fossilUnquote` would /// reject as malformed. Scanning it as a bare token is what lets a repository /// with user avatars parse at all. /// /// `photo` and `jx` are SKIPPED, not stored: an avatar and a JSON extension /// blob have no zit consumer, and carrying bytes nothing reads would be /// storage this side cannot justify. An UNKNOWN field name is skipped the /// same way (fossil's own receive loop does exactly this: `if( jj>=nField ) /// continue;`), so a future fossil column cannot make a row unparseable. pub fn parseUserConfigPayload(payload: []const u8) error{Malformed}!QuotedUserRow { const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed; const mtime_text = payload[0..sp1]; if (mtime_text.len == 0) return error.Malformed; var i = sp1 + 1; while (i < payload.len and payload[i] == ' ') i += 1; if (i >= payload.len or payload[i] != '\'') return error.Malformed; const login_end = scanQuoted(payload, i) orelse return error.Malformed; var row: QuotedUserRow = .{ .mtime_text = mtime_text, .quoted_login = payload[i..login_end] }; i = login_end; // The FIELD VALUE pairs. A trailing field name with no value is // malformed, not silently dropped: fossil's own loop advances only on a // complete pair, so a half pair means the payload was truncated. while (true) { while (i < payload.len and payload[i] == ' ') i += 1; if (i >= payload.len) break; const name_start = i; while (i < payload.len and payload[i] != ' ') i += 1; const name = payload[name_start..i]; if (name.len == 0) return error.Malformed; while (i < payload.len and payload[i] == ' ') i += 1; if (i >= payload.len) return error.Malformed; // field with no value const value_start = i; if (payload[i] == '\'') { i = scanQuoted(payload, i) orelse return error.Malformed; } else { while (i < payload.len and payload[i] != ' ') i += 1; } const value = payload[value_start..i]; if (std.mem.eql(u8, name, "pw")) { row.quoted_pw = value; } else if (std.mem.eql(u8, name, "cap")) { row.quoted_cap = value; } else if (std.mem.eql(u8, name, "info")) { row.quoted_info = value; } // else: `photo`, `jx`, or a field a later fossil added — skipped. } return row; } /// Payload ceiling for one `/user` row: a signed decimal mtime, a SP-and- /// quoted login, and three ` NAME ` + quoted fields whose every byte may /// double under `fossilQuote`. pub const user_config_payload_max: usize = 20 + 1 + (2 + 2 * 32) + 3 * (" info ".len + 2 + 2 * 255); /// One `/user` row's payload in `configure_send_group`'s format, byte for /// byte: `MTIME 'LOGIN' pw 'PW' cap 'CAP' info 'INFO'`. All three fields are /// always emitted — fossil omits only NULL columns, and zit models an absent /// field as the empty string, which is `''` on the wire and which a receiving /// fossil stores as empty rather than NULL. `photo`/`jx` are never emitted: /// this side does not store them, and inventing values would be worse than /// leaving the receiver's own columns untouched. pub fn writeUserConfigPayload(row: UserRow, out: []u8) std.fmt.BufPrintError![]u8 { if (out.len < user_config_payload_max) return error.NoSpaceLeft; var i: usize = 0; const mtime = try std.fmt.bufPrint(out[i..], "{d}", .{row.mtime}); i += mtime.len; out[i] = ' '; i += 1; i += fossilQuote(row.login, out[i..]).len; for ([_]struct { name: []const u8, value: []const u8 }{ .{ .name = "pw", .value = row.pw }, .{ .name = "cap", .value = row.cap }, .{ .name = "info", .value = row.info }, }) |f| { out[i] = ' '; i += 1; @memcpy(out[i..][0..f.name.len], f.name); i += f.name.len; out[i] = ' '; i += 1; i += fossilQuote(f.value, out[i..]).len; } return out[0..i]; } /// One user-table row as the wire carries it, unquoted. Mirrors fossil's /// `user` table columns that zit models — `(login, pw, cap, info, mtime)`. pub const UserRow = struct { login: []const u8, pw: []const u8, cap: []const u8, info: []const u8, mtime: i64, }; /// §9.3 r25 L1893-1896 — 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`. /// One `MTIME 'KEY' <field> 'VALUE'` config payload, still quoted. The mtime /// is left as TEXT: `/config` reads it unsigned and `/shun` signed, and a /// parser that picked one would force the other to launder a value it never /// had a reason to reject. pub const KeyedConfig = struct { mtime_text: []const u8, quoted_name: []const u8, quoted_value: []const u8, }; /// The shared row grammar behind every fossil config group `configure_receive` /// handles: an mtime, a quoted primary key, a FIELD NAME, and a quoted value. /// Only the field name differs between groups — `value` for `/config`, `scom` /// for `/shun`, `content` for `/concealed` — so it is a parameter here rather /// than a second copy of the scanner. pub fn parseKeyedConfig(payload: []const u8, field: []const u8) error{Malformed}!KeyedConfig { const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed; const mtime_text = payload[0..sp1]; if (mtime_text.len == 0) 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; if (i + field.len > payload.len or !std.mem.eql(u8, payload[i .. i + field.len], field)) return error.Malformed; i += field.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; return .{ .mtime_text = mtime_text, .quoted_name = quoted_name, .quoted_value = payload[i..value_end] }; } pub fn parseScalarConfig(payload: []const u8) error{Malformed}!ScalarConfig { const keyed = try parseKeyedConfig(payload, "value"); const mtime = std.fmt.parseInt(u64, keyed.mtime_text, 10) catch return error.Malformed; const result: ScalarConfig = .{ .mtime = mtime, .quoted_name = keyed.quoted_name, .quoted_value = keyed.quoted_value }; 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 L1928-1929 — 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 9.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 L1967-1970 — zit-local (the RFC only records the fault): 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 L1967-1970 (RFC records the fault only): no recorded fault -> commit proceeds std.debug.assert(!override_requested or result); // §9.4 L1967-1970: RFC states no commit rule; zit-local 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 9.5 — Private-Content and Discovery Pragmas // --------------------------------------------------------------------------- /// ABNF App. B — 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, .cone, // carries one F-card-encoded member .@"cone-not", // same shape, negative polarity .@"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 L1983-1985 (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 §B `client-url-pragma` L2929 — 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 // --------------------------------------------------------------------------- /// §7.6 L1392-1394, App. A.2 decoded text L2547 — 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]; } /// §10.1 r1/r2 L2055-2057, §10.2 r1/r2 L2116-2118 — 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 }; /// §10.1 r4 L2057-2061 (outside clone round 0: terminate) / r10 L2064-2067 /// (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 }; /// §10.1 L2093-2096 (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 }; /// §10.2 L2133-2137, 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 — 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 L2542-2545: 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) L581-586). 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) // A clone-seqno card was present this round -- distinguishes "NEXT is 0" // (§A.2 exhausted-range progress) from "no clone card at all" (the // sparse-cone §5.3 refusal shape). clone_seqno_received: bool = false, 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, // Sparse-cone RFC section 5.3 -- an error card was discarded under the // sync RFC section 11.1 clone-round-0 rule. Folded with the progress // flags at end of round: a reply that carried an error card AND no clone // progress is a refusal, not cosmetics, and the clone aborts loudly // instead of "succeeding" empty. error_card_discarded: bool = false, // §7.5 L1376-1377 — a private igot recorded that the peer holds HASH. private_igot_peer_has: bool = false, }; /// §3.7(a) L577-579 — 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(d) L596-601 — 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(e) L603-612 — 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 L574-607, L621-622 — (f) is zit-local; 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) } /// Did this round MOVE BYTES — content delivered into the store, or content /// queued out of it? This is not a §3.7 condition and never decides whether to /// issue another round; `continueExchange` alone does that. It exists for the /// driver's runaway guard, which needs to tell a peer that is converging slowly /// from one that is looping while accomplishing nothing. /// /// The distinction that matters is DELIVERY versus ANNOUNCEMENT. A peer can /// satisfy §3.7 forever without ever converging: announce a fresh hash each /// round and condition (a) holds indefinitely — a new phantom every round, a /// phantom table that is never empty, and content that never arrives. So /// `phantom_created` is deliberately NOT progress here, nor is anything else /// that merely records what the peer claims or what is still pending /// (`private_discovery_pending`, `config_exchange_pending`, `uvgimme_queued`, /// `cookie_stored`, `phantom_marked_private`, `private_igot_peer_has`). Only /// bytes crossing in either direction count. /// /// Consequence, and the reason this function exists: a round count is NOT a /// convergence bound. A clone transfers a bounded batch per round, so a flat /// round cap silently becomes a ceiling on repository SIZE — a large, perfectly /// healthy repository trips it while making full progress every round. Bounding /// consecutive byte-free rounds instead bounds the thing actually being guarded /// against, and does so independently of how much there is to transfer. pub fn roundDeliveredContent(obs: RoundObservation) bool { return obs.versioned_content_delivered or // §3.3 — content into the store obs.content_queued or // §3.7(b) — content out of it obs.uvfile_received or // §3.5 — an unversioned file arrived obs.artifact_received_this_round or // §8.2 — a clone/cfile delivery obs.artifact_count_grew or // §3.7(a) L577-578 — stored content obs.private_content_delivered; // §7.5 L1362-1363 } // --------------------------------------------------------------------------- // 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 Table 5 L733-746 — 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 — zit's closed nine-cause fatal-error set, each member 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, // zit-local cap; §5.2 L961-962 imposes no maximum redirect_limit, // §4 L845-846 / Table-3 r15 clone_seqno_negative, // zit-local; §8.2 L1521-1525 / §3.2 L419 tolerate it }; /// §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 L1521-1525 / §3.2 L419 tolerate a nondecimal NEXT; zit's abort is zit-local, 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 (§5.2 L944-946, L957-959, L964-967). // --------------------------------------------------------------------------- // Section 3.2.1 — 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 the /// artifact-format §3.2 L347-350 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. /// Sparse-cone RFC section 6.2 -- the server-side cone classifier seam. The /// library never parses manifests during a reply; the embedder (which owns /// the snapshot and a memo) answers "which rids does this cone keep". A null /// return serves densely -- refusal to classify is degradation, never error. /// Sparse-cone RFC section 5.3 -- may this sparse origin serve a clone for /// the requested cone? A dense request (no cone members) asks for everything /// and is refused. A cone-scoped request is admitted only when every /// requested inclusion lies WHOLLY inside the origin's coverage: covered by /// the origin cone, with no origin exclusion inside its subtree. Requester /// exclusions only narrow a request, so they never affect admission. /// `members` are F-card-encoded exactly as they crossed the wire; an /// undecodable or over-long member refuses (fail closed -- the requester /// asked for something this origin cannot even name). fn cloneConeAdmissible(origin: *const cone_mod.Cone, members: []const []const u8) bool { if (members.len == 0) return false; std.debug.assert(members.len <= cone_mod.max_members); // RequestScan caps collection. for (members) |encoded| { // Decoding never grows: the decoded member fits an encoded-size // buffer. An over-long member cannot have crossed a legal F card. if (encoded.len > 2 * cone_mod.max_member_len) return false; var buf: [2 * cone_mod.max_member_len]u8 = undefined; const member = artifact.decodeEscapes(encoded, buf[0..encoded.len]); if (member.len == 0) return false; if (!origin.covers(member)) return false; for (origin.not_members) |not_member| { if (memberCoversForOrigin(member, not_member)) return false; } } return true; } /// Prefix-at-'/'-boundary coverage, restated here because cone.zig keeps its /// helper private: does `outer` cover `inner`? fn memberCoversForOrigin(outer: []const u8, inner: []const u8) bool { std.debug.assert(outer.len >= 1); if (inner.len < outer.len) return false; if (!std.mem.eql(u8, outer, inner[0..outer.len])) return false; if (inner.len == outer.len) return true; return inner[outer.len] == '/'; } pub const ConeClassifier = struct { context: *anyopaque, vtable: *const VTable, pub const VTable = struct { /// `members` are F-card-ENCODED cone members exactly as they crossed /// the wire. Returns a keep bitset over the server store's rids, or /// null to serve densely (bad member, over-cap, memo failure). The /// bitset borrows the embedder's memo and MUST stay valid for the /// reply being built. classify: *const fn ( context: *anyopaque, members: []const []const u8, not_members: []const []const u8, ) ?*const std.DynamicBitSetUnmanaged, }; pub const none: ConeClassifier = .{ .context = undefined, .vtable = &.{ .classify = neverClassify } }; fn neverClassify(_: *anyopaque, _: []const []const u8, _: []const []const u8) ?*const std.DynamicBitSetUnmanaged { return null; } }; 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, /// Sparse-cone RFC section 5.2 rule 3 -- deliberately absent under local /// cone policy. Distinct from `shunned`: shun is a durable prohibition /// that also silences SERVING; elision only silences spontaneous /// REQUESTING, and a targeted demand fetch (a later wave) asks anyway. elided: bool = false, }; /// §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.elided) return false; // RFC 5.2(3): never spontaneously requested if (e.private and !e.private_sync_enabled) return false; const result = true; std.debug.assert(!e.peer_lacks); std.debug.assert(!e.shunned); std.debug.assert(!e.elided); return result; } pub const GimmeResponse = enum { send, silent_skip }; /// §3.3 L532-535 — 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, §8.6 L1753-1755 — write the holding-announcement card /// `igot HASH` (public) or `igot HASH 1` (private) into `out`, no /// terminating LF. The trailing `1` is the §8.6/§5.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 §8.6 L1753-1754: 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, L2500-2501 — 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 L527-529: 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 L1521-1523 + §3.7 L609-610 — no draft rule makes this a server MUST: the client never validates advancement, so this is a zit-local invariant that 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/L1214-1216 — 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). The count backstop is exercised through the test knob — // the shipped default is byte-bound long before 65,536 tiny cards. const batch_cap: usize = 4; const total = batch_cap + 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", .clone_batch_max = batch_cap }; // Batch 1 from SEQNO 1: exactly batch_cap 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(batch_cap, cfiles1); try std.testing.expectEqual(@as(i64, @intCast(batch_cap + 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", .{batch_cap + 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 // --------------------------------------------------------------------------- /// §8.5 L1631-1632 — 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 }; /// §8.5 L1631-1632 — 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 }); } /// §8.5 L1631-1632 — 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 }; /// §8.5 L1631-1632 — 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] }; } /// §8.5 L1632-1633 — 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; } /// §8.6 L1755-1757, §9.1 L1853-1854 (DATE is YYYYMMDD) — the private- /// igot suppression threshold as an integer. pub const private_igot_min_peer_date: u32 = 20200413; /// §8.6 L1755-1757 — 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; } /// A zit-local cap, not a protocol one — §4 L830-833 disclaims any 512 MiB entity limit; this is our own bound on a Content-Length-less /// reply body (512 MiB). pub const reply_body_cap: usize = 536_870_912; /// zit-local, not protocol (§4.1 L913-914: "This protocol specifies no separate 512 MiB limit") — 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 L2191-2193); deployments carrying such traffic SHOULD use HTTPS, /// SSH, or an equivalent confidential, integrity-protected, server- /// authenticated transport (§11.2 L2194-2195). Refusing an /// HTTPS->HTTP redirect is this library's sole in-boundary contribution; the /// §11.2 exposure observation (L2191-2194 — 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); } /// §9.1 — a stable, distinct SERVERCODE for this repository INSTANCE, the /// same derivation seam as `deriveProjectCode` but domain-separated so the /// two codes never coincide over identical entropy. Servercode is /// informational on the wire (§9.1: never validated by a peer), so deriving /// it from the instance's absolute path is the whole requirement: stable per /// checkout, distinct across clones of one project — and a moved repository /// minting a new instance identity is exactly what a new path is. Never /// adopted from a peer, unlike the project code. pub fn deriveServerCode(entropy_parts: []const []const u8) HashHex { std.debug.assert(entropy_parts.len <= 7); var parts: [8][]const u8 = undefined; parts[0] = "zit-servercode/"; for (entropy_parts, parts[1 .. entropy_parts.len + 1]) |p, *slot| slot.* = p; return sha1HexOf(parts[0 .. entropy_parts.len + 1]); } /// §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 + §6.1 L1077-1078 + §6.3 L1093: 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 L1134-1135 — 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,L1167-1168 — 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) + L1181-1182 (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 L830-833 — the retention ceiling. Not an arbitrary constant, and not a protocol limit: §4 imposes no entity ceiling, so this rests on a zit-local one — /// 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 this locally derived bound. pub const max_pending: usize = reply_body_cap / min_delta_card_bytes; pub const RetainError = error{QueueFull} || std.mem.Allocator.Error; /// Slot arena: a retained entry keeps its index for life (null = free), /// so both indexes below hold STABLE handles and removal never compacts. /// Slot count never exceeds the ceiling (`live <= cap` and slots are /// reused through `free_slots` before the arena grows). items: std.ArrayListUnmanaged(?PendingDelta) = .empty, /// Reusable freed slots. Invariant: capacity >= items.len at all times /// (re-established after every arena growth), so returning a slot to the /// free list can never fail — removal paths stay infallible. free_slots: std.ArrayListUnmanaged(usize) = .empty, /// Occupied slots — the queue's logical size. live: usize = 0, /// O(1) `hasTarget`: live-entry count per target hash. The gimme driver /// consults the predicate once per phantom per round, so membership must /// not cost a queue scan (a delta-heavy peer retains 10^5+ entries). /// Keys are map-owned copies — an entry's own hash bytes die with the /// entry, and duplicate targets share one counted key. by_target: std.StringHashMapUnmanaged(u32) = .empty, /// O(1) dependents-of-DELTASRC: slot indices per basis hash, maintained /// by `retain` and drained whole by `completeFor` — a completion fires /// once per stored artifact, so resolving its dependents must not cost a /// queue scan either. A bucket's key borrows the deltasrc bytes of one /// of its own entries, and the bucket leaves the map before any of its /// entries' bytes are freed. by_src: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(usize)) = .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.live; } /// 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) |maybe| if (maybe) |entry| freeEntry(gpa, entry); self.items.deinit(gpa); self.free_slots.deinit(gpa); var keys = self.by_target.keyIterator(); while (keys.next()) |k| gpa.free(k.*); self.by_target.deinit(gpa); var buckets = self.by_src.valueIterator(); while (buckets.next()) |b| b.deinit(gpa); self.by_src.deinit(gpa); } /// One entry naming `hash` as target is leaving the queue: decrement its /// live count, dropping (and freeing) the map key at zero. fn releaseTarget(self: *PendingDeltaQueue, gpa: std.mem.Allocator, hash: []const u8) void { const count = self.by_target.getPtr(hash) orelse unreachable; // every entry was counted in count.* -= 1; if (count.* == 0) { const kv = self.by_target.fetchRemove(hash).?; gpa.free(kv.key); } } 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.live <= self.cap); if (self.live >= 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()); if (self.by_target.getPtr(hash_owned)) |count| { count.* += 1; } else { const key = try gpa.dupe(u8, hash_owned); self.by_target.put(gpa, key, 1) catch |err| { gpa.free(key); return err; }; } errdefer self.releaseTarget(gpa, hash_owned); // Acquire a slot, keeping the free-list capacity invariant so every // later slot release is infallible. const slot: usize = if (self.free_slots.pop()) |reused| reused else grow: { try self.items.append(gpa, null); try self.free_slots.ensureTotalCapacity(gpa, self.items.items.len); break :grow self.items.items.len - 1; }; errdefer self.free_slots.appendAssumeCapacity(slot); // Index by basis. A fresh bucket's key borrows THIS entry's owned // deltasrc copy; an existing bucket's key belongs to another live // entry with the same basis, drained in the same completion. const gop = try self.by_src.getOrPut(gpa, src_owned); if (!gop.found_existing) gop.value_ptr.* = .empty; gop.value_ptr.append(gpa, slot) catch |err| { if (gop.value_ptr.items.len == 0) _ = self.by_src.remove(src_owned); return err; }; // Everything fallible is done: seat the entry. self.items.items[slot] = .{ .hash = hash_owned, .deltasrc = src_owned, .payload = payload_owned }; self.live += 1; std.debug.assert(self.live <= 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_live = self.live; var work: std.ArrayListUnmanaged([]const u8) = .empty; defer work.deinit(gpa); try work.append(gpa, deltasrc); var done: usize = 0; while (done < work.items.len) { const src = work.items[done]; done += 1; // Drain the whole bucket for this basis: O(dependents), found in // O(1) — a completion fires once per stored artifact, so this // must never rescan the queue. var bucket = (self.by_src.fetchRemove(src) orelse continue).value; defer bucket.deinit(gpa); var j: usize = 0; // A completion error aborts the round; the entries already // processed left the queue (exactly as the old eager // swap-remove guaranteed) and the unprocessed tail — including // the failing entry — is re-homed so it stays reachable. errdefer self.rehomeTail(gpa, &bucket, j); while (j < bucket.items.len) { const i = bucket.items[j]; // `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). Amortized append — // a per-call whole-queue reservation costs O(live) on EVERY // stored artifact; an append failure aborts the round like // any completion error, with the same rehome unwind. std.debug.assert(work.items.len < initial_live + 1); try work.append(gpa, store.hashOf(target_rid)); } j += 1; self.items.items[i] = null; self.free_slots.appendAssumeCapacity(i); self.live -= 1; self.releaseTarget(gpa, entry.hash); freeEntry(gpa, entry); } } std.debug.assert(work.items.len <= initial_live + 1); // §7.4 fixpoint terminates } /// A completion aborted mid-bucket: re-key the unprocessed tail /// (including the failing entry, still seated in its slot) under the /// failing entry's own DELTASRC copy, so those entries stay reachable /// for a later round. On OOM during this unwind the tail entries stay /// live in their slots (`deinit` still frees them) but leave `by_src` — /// they can no longer complete this session, exactly as if their basis /// never arrived (§7.7 permits ending with unresolved phantoms). fn rehomeTail(self: *PendingDeltaQueue, gpa: std.mem.Allocator, bucket: *const std.ArrayListUnmanaged(usize), j: usize) void { if (j >= bucket.items.len) return; const key_owner = self.items.items[bucket.items[j]].?; const gop = self.by_src.getOrPut(gpa, key_owner.deltasrc) catch return; if (!gop.found_existing) gop.value_ptr.* = .empty; gop.value_ptr.appendSlice(gpa, bucket.items[j..]) catch { if (gop.value_ptr.items.len == 0) _ = self.by_src.remove(key_owner.deltasrc); }; } /// §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. // Only the delta HEADER is needed here — the target size is // its first bytes — so inflate just that prefix; the full // inflate ran once per received delta and was a visible slice // of a clone's client CPU. const raw = codec.decodePrefix(gpa, container, 16) 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, and /// O(1) — the gimme driver asks once per phantom per round. pub fn hasTarget(self: *const PendingDeltaQueue, hash: []const u8) bool { std.debug.assert(self.live <= self.cap); std.debug.assert(hash.len == 40 or hash.len == 64); return self.by_target.contains(hash); } }; // §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; } /// §8.6 L1753-1754 / §B L2865-2866 — 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 }; /// §8.6 L1753-1754 / §B L2865-2866 — 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 L532-535 / §B L2867 — 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 8.5.1 — Unversioned-File Synchronisation // --------------------------------------------------------------------------- /// §8.5.1 L1669-1670, L1700-1703 — 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 8.6.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 => 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()); }