//! Sync Protocol — draft-fossil-sync-protocol-00.
//!
//! Two repositories reconcile a content-addressed artifact set over HTTP. The
//! protocol is client-driven; the server is stateless across rounds, so only
//! the client runs a state machine (Section 3.8).
const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const delta = @import("delta.zig");
const flate = std.compress.flate;
/// 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 };
pub const Error = error{ FatalProtocolError, NotAuthorized, NotImplemented };
/// Robustness posture (Section 3.9): conservative in what it sends, strict in
/// what it accepts. An unknown non-`pragma` operator and a malformed content
/// card are fatal; liberality is confined to the four enumerated points.
pub const Client = struct {
state: ClientState = .init,
operation: Operation = .sync,
// 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.
phantoms: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
/// §3.1 L605-608, §3.2 L763-766 — the per-session outbound send buffer:
/// rids of held artifacts the peer requested with gimme and this side may
/// push. It persists across rounds (never reset at a round boundary) and is
/// drained when the caller builds the next request. Rid is a stable key —
/// `store.lookup` resolves the gimme'd hash to the rid its content lives
/// under, so what is queued is exactly what gets uploaded.
send_queue: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
/// §10.1 r13 — the peer record, updated by each server-version pragma.
peer: Peer = .{ .version = 0 },
/// §5.3, §A.4 — the last `# timestamp DATETIME errors N` comment seen; read
/// for clock skew (the skew computation itself is deferred).
last_timestamp: ?TimestampComment = null,
/// §11.2 item 1/3 — a one-way SYNC_PUSH latch. A `pull only *` message
/// received while push intent is live sets this for the rest of the session
/// (r6); a round boundary never resets it, and setting it edits no send-
/// buffer bytes (r10). The live push flag is derived with `operation` in
/// `pushActive`, so no construction site restates the operation->push map.
push_cleared: bool = false,
/// §3.6 L913-916 — the client's single held sync cookie. A server reply's
/// cookie card overwrites it; a round boundary never resets it. Inline
/// buffer, no heap — `deinit` needs no change for it.
cookie: CookieStore = .{},
/// §7.5 L2359-2367 — client receive-side pending private marker. Per-session:
/// persists across consumeReply bodies (r5); the next file/cfile card consumes
/// it at handler entry (r4), a fresh Client (new session) starts clear (r6). A
/// bare private card sets it, tolerant of intervening comment/non-content
/// cards (r2), and a repeat leaves it set (r3). A round boundary never resets
/// it — same per-session lifetime as push_cleared/cookie.
private_pending: bool = false,
/// §3.2/§3.7/§3.8 — the closed error set `consumeReply` returns, so the CLI
/// can switch it exhaustively. `NotImplemented` fences the out-of-scope-but-
/// legal reply cards (cfile/uvfile/private/uvigot/uvgimme and the delta-form
/// file card) as a strict boundary — a legal card is never dispatched to a
/// wrong answer. Auth failures never surface here: they arrive as `error`
/// cards and route through `clientOnErrorCard`.
pub const ConsumeError = error{ OutOfMemory, FatalProtocolError, NotImplemented };
/// Frees the phantom set and the send buffer. The Rid keys own no memory,
/// so this is just the maps' own storage.
pub fn deinit(self: *Client, gpa: std.mem.Allocator) void {
self.phantoms.deinit(gpa);
self.send_queue.deinit(gpa);
self.* = undefined;
}
/// §3.1 L605-608, §3.2 L763-766 — copy the send buffer's rids into `out`
/// and empty the buffer. The caller sizes `out` from `send_queue.count()`
/// and encodes one file card per rid for the next request; returns the
/// count written. Total and allocation-free — the capacity bound is
/// asserted, never risked. The buffer is emptied only here (an explicit
/// upload), never by a round boundary, per §3.1's per-session rule.
pub fn drainSendQueue(self: *Client, out: []repostate.Rid) usize {
const count = self.send_queue.count();
std.debug.assert(out.len >= count);
var i: usize = 0;
var it = self.send_queue.keyIterator();
while (it.next()) |rid| {
out[i] = rid.*;
i += 1;
}
std.debug.assert(i == count);
self.send_queue.clearRetainingCapacity();
std.debug.assert(self.send_queue.count() == 0);
return count;
}
/// §11.2 item 1/3a — the single live-SYNC_PUSH predicate every push-gated
/// append site reads: push intent is live iff the operation direction
/// sends content (§3.3) AND no `pull only *` message has cleared it. Once
/// cleared it stays cleared (r6), so the two conditions never both hold.
pub fn pushActive(self: *const Client) bool {
const active = clientSendsContent(self.operation) and !self.push_cleared;
std.debug.assert(!(self.push_cleared and active)); // negative space
return active;
}
/// §3.2/§3.7/§3.8 — consume one reply body: dispatch each card, apply store
/// effects, evaluate the §3.7 continuation, advance the §3.8 state. Bytes in
/// / RoundObservation out; the transport is the caller's (sans-IO).
/// Precondition: a reply is dispatched only mid-exchange (`exchange`) or in
/// a clone's first round (`clone_round0`) — the two reply-bearing states of
/// §3.8 Table 3.
pub fn consumeReply(
self: *Client,
gpa: std.mem.Allocator,
store: repostate.ArtifactStore,
body: []const u8,
) ConsumeError!RoundObservation {
std.debug.assert(self.state == .exchange or self.state == .clone_round0);
var rt = RoundTables.init(gpa);
defer rt.deinit();
var obs = RoundObservation{ .operation = self.operation };
// §5.1 — the body is a flat LF-delimited card stream; the cursor strictly
// advances (a line consumes ≥1 byte through its LF; a file card also
// consumes its byte-counted payload), so the loop is bounded by body.len.
var cursor: usize = 0;
while (cursor < body.len) {
std.debug.assert(cursor < body.len);
const line = readLine(body, cursor) catch return self.abort();
var next_cursor = line.next;
if (isCommentLine(line.bytes)) {
// §5-r25, §A.4 — a timestamp comment updates the skew record;
// any other comment (nonce, `# sending-clusters`) is skipped.
if (parseTimestampComment(line.bytes)) |tc| self.last_timestamp = tc;
} else {
const cc = classifyCard(line.bytes);
if (cc.operator == null or isFatal(cc.disposition, .client)) return self.abort();
next_cursor = try self.dispatchCard(gpa, store, &rt, &obs, cc.operator.?, cc.tokens, body, line.next);
}
std.debug.assert(next_cursor > cursor);
cursor = next_cursor;
}
std.debug.assert(cursor == body.len);
// §7.5 L2369-2371 — a body may end with private_pending still set (no
// file/cfile card arrived to consume it): not by itself a fatal error, and
// it is deliberately not cleared here — it persists to the next reply body
// in this session (r5).
// §3.7(a)/(b) — the continuation flags read the final table state. A
// phantom created earlier in the body may have been dephantomized by a
// later file card, so phantom_created does not imply a non-empty table;
// phantom_table_nonempty, computed here, is the sole §3.7(a) authority.
obs.phantom_table_nonempty = self.phantoms.count() > 0;
if (self.state == .clone_round0) {
// §3.8 r14 — a processed clone reply advances unconditionally.
self.state = transition(.clone_round0, .clone_reply_processed);
} else {
const ev: Event = if (continueExchange(obs)) .round_continues else .round_terminates;
self.state = transition(.exchange, ev);
}
return obs;
}
/// Dispatch one classified card (§3.2), returning the offset the loop
/// resumes at — the payload end for a `file`/`cfile` card, otherwise the
/// next line.
/// Exhaustive over all 19 operators: no reply card is silently unhandled.
fn dispatchCard(
self: *Client,
gpa: std.mem.Allocator,
store: repostate.ArtifactStore,
rt: *RoundTables,
obs: *RoundObservation,
op: Operator,
tokens: Tokens,
body: []const u8,
payload_start: usize,
) ConsumeError!usize {
switch (op) {
.pragma => {
// §10.1 — record a server/client-version pragma; §3.5 — any
// other pragma name is tolerated and ignored here.
if (tokens.len >= 2 and isVersionPragmaName(tokens.items[1])) {
if (parseVersionPragma(tokens)) |v| recordPeerVersion(&self.peer, v) else |_| {}
}
return payload_start;
},
.push, .pull => {
// §9.1 — validated (arity) here; project-code persistence and
// auth are a later batch. Arity < 3 is malformed → fatal.
if (parsePushPull(tokens) == null) return self.abort();
return payload_start;
},
.clone => {
// §3.9 — a client→server request-initiation card is never legal
// in a server reply: a strict-accept protocol violation, not an
// unimplemented feature.
return self.abort();
},
.file => {
// §7.5 L2363 — the marker is consumed the moment file handling
// begins, before arity/hash validation; a staged delta-form card
// (below) therefore consumes it too, which is correct: it is still
// a file card whose handling began (its discarded obs makes the
// binding moot regardless).
const was_private = self.takePrivateMarker();
// §7.1 — full-content (two-argument) form only. The delta form
// (file HASH DELTASRC SIZE) needs the uncomposed
// PendingDeltaQueue.retain seam and is staged.
if (tokens.len == 4) return ConsumeError.NotImplemented;
const fc = parseFileCard(tokens) catch return self.abort();
std.debug.assert(fc.deltasrc == null);
const payload = consumeContentPayload(body, payload_start, fc.size) catch return self.abort();
const rid = try store.put(fc.hash, payload.bytes);
// §3.7(a) — arriving content dephantomizes; the remove is a
// no-op for a hash never phantom'd (underflow defined away).
_ = self.phantoms.remove(rid);
obs.versioned_content_delivered = true;
if (was_private) obs.private_content_delivered = true; // §7.5 L2359
return payload.next;
},
.igot => {
// §3.3 r24/r25 — an igot creates a phantom only when the
// artifact is unheld and this side can obtain it. The outer
// 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 card = parseIgotCard(tokens) catch return self.abort();
const rid = try store.intern(card.hash);
if (igotDisposition(.client, self.operation, store.content(rid) != null) == .create_phantom) {
const gop = try self.phantoms.getOrPut(gpa, rid);
if (!gop.found_existing) obs.phantom_created = true;
}
return payload_start;
},
.gimme => {
// §3.3 L848 — a gimme always records that the peer lacks HASH.
// The 40/64-hex length recordPeerLacks requires is enforced by
// parseGimmeCard, so it is never asserted on wire input.
const g = parseGimmeCard(tokens) catch return self.abort();
try rt.recordPeerLacks(g.hash);
// §3.2 L761-766 / §3.3 L849-856 — if this side holds HASH and
// the operation direction permits sending, queue its rid in the
// per-session send buffer (§3.1 L605-608) for the next request.
// lookup, not intern: an unheld or garbled gimme creates no
// phantom, keeping §3.3's phantom set closed. §3.7(b): a fresh
// append this round sets the per-round content_queued flag.
if (store.lookup(g.hash)) |rid| {
const holds = store.content(rid) != null;
// policy_permits is true: shun/private disclosure gating is
// not modeled at this layer yet, as at every gimmeResponse
// call site.
// §11.2 r11 — the send gate reads the *live* push flag, not
// the immutable operation intent: a `pull only *` message
// earlier in this same reply clears it and suppresses a
// later gimme's content append. r12 (an earlier gimme's
// append is retained) falls out of in-order dispatch.
if (gimmeResponse(holds, self.pushActive(), true) == .send) {
const gop = try self.send_queue.getOrPut(gpa, rid);
if (!gop.found_existing) obs.content_queued = true;
}
}
return payload_start;
},
.clone_seqno => {
// §8.2 L2010-2013, §3.8 r19 — validate NEXT's sign at parse: a
// negative wire value is a fatal protocol error, never stored
// into obs.clone_seqno_next where continueExchange's `>= 0`
// precondition would otherwise trip on wire data.
const n = parseCloneSeqno(tokens) catch return self.abort();
if (cloneSeqnoIsFatal(n)) return self.abort();
std.debug.assert(n >= 0);
obs.clone_seqno_next = n;
return payload_start;
},
.@"error" => {
// §11.1 — an error card is data, not a directive: discarded in a
// clone's first round, otherwise it aborts the exchange.
return switch (clientOnErrorCard(self.state)) {
.discard => payload_start,
.terminate => self.abort(),
};
},
.message => {
// §11.2 r1/r3 — a message card never aborts; the only format
// rejection is §5 surplus (>6 tokens), fired pre-dispatch by
// classifyCard. A card with no TEXT token has nothing to
// decode, match, or display (negative space).
if (tokens.len < 2) return payload_start;
const encoded = tokens.items[1];
std.debug.assert(encoded.len <= max_line_len); // alloc bound
// §11.2 r2 — defossilize before matching; the decoded form never
// grows past the encoded token, and the scratch is freed here.
const scratch = try gpa.alloc(u8, encoded.len);
defer gpa.free(scratch);
const decoded = artifact.decodeEscapes(encoded, scratch);
// §11.2 item 1/2 — clear SYNC_PUSH iff push is live and the
// payload matches `pull only *` (r4); otherwise the advisory
// path (r9), which changes no flag. This sans-IO layer performs
// no display, so r5 suppression holds trivially and r9's
// MAY-display is the CLI caller's choice.
switch (clientOnMessageCard(self.pushActive(), decoded)) {
.pull_only_silent => {
self.push_cleared = true; // r6/r10/r13 — one flag, latched
std.debug.assert(!self.pushActive()); // now clear
},
.display_optional => {},
}
return payload_start;
},
.cookie => {
// §3.6 L913-916, §4.3 — the server MAY emit a cookie; the
// client MUST store the opaque TEXT, replacing any held one.
// §B.4 L4622 `cookie-card = "cookie" SP fossilized LF`: a bare
// `cookie` with no TEXT fails the card's arity check, a fatal
// client abort (§5.2 L1809-1813). §3.9 L1312 tolerance is scoped
// to an unexpected server, not to a missing argument token.
if (tokens.len < 2) return self.abort();
std.debug.assert(tokens.items[1].len <= max_line_len);
self.cookie.storeFossilized(tokens.items[1]);
obs.cookie_stored = true;
// §5.1 — cookie carries no byte-counted payload; resume at the
// next line, not past any content bytes.
return payload_start;
},
.login, .reqconfig => {
// §6.1 L1919-1920 (login is a request-only card, "carries
// exactly one login card") / §10.3 L3272 (reqconfig is
// client→server, "A client sends reqconfig NAME"). Either in a
// server reply is a strict-accept violation (§3.9 L1314-1315:
// "a receiver applies a defined rule or aborts").
return self.abort();
},
.config => {
// §10.3 L3278/L3320-3322 — `config GROUPNAME SIZE` then SIZE
// content bytes and one mandatory trailing LF. Frame by the
// byte count, never by scanning; a malformed header or a frame
// past the body is a fatal content error (§3.9).
const cc = parseConfigCard(tokens) catch return self.abort();
const p = consumeConfigPayload(body, payload_start, cc.size) catch return self.abort();
return p.next;
},
.cfile => {
// §7.5 L2363 — consume the pending marker at cfile handler entry,
// before arity/hash validation (see the file arm and
// takePrivateMarker), so the staged delta form clears it as well.
const was_private = self.takePrivateMarker();
// §7.2-r13 — the delta form (cfile HASH DELTASRC USIZE CSIZE,
// 5 tokens) needs the §7.4 PendingDeltaQueue.retain basis seam
// and is staged, mirroring the file delta form above.
if (tokens.len == 5) return ConsumeError.NotImplemented;
// §7.2-r1/r10 — full-content form only; 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();
std.debug.assert(cc.deltasrc == null); // negative space: full-content path never sees a basis
// §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();
// §7.2-r5/r9/r12 — the payload is one §4.1 stored container,
// already outer-decoded, so it is container-decoded exactly
// once. 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.
const decoded = zlibContainerDecode(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-r6/r7/r8 — store the decompressed bytes under HASH (the
// store dupes), recording versioned progress; the store contract
// dephantomizes, no sync-layer wrapper. §7.7: no pre-storage
// hash recompute at the transfer layer.
const rid = try store.put(cc.hash, decoded);
_ = self.phantoms.remove(rid);
obs.versioned_content_delivered = true; // §3.7(a) / §8.2 r14 — identical to a full file card
if (was_private) obs.private_content_delivered = true; // §7.5 L2359
return payload.next; // §7.2-r3 — resume past exactly CSIZE payload bytes
},
.private => {
// §7.5 L2359 — a bare `private` (arity 1, ABNF L4643
// `private-card = "private" LF`) sets the receive-side pending
// marker for the next file/cfile card. A defined operator with a
// bad visible arg count is that card's own §5.2 arity violation
// (L1770-1776): the client aborts the round with no outbound error
// card (§5.2 L1809-1813, §3.9). A 7th+ token is already fatal at
// classifyCard (surplus → .bad_command → isFatal client), so this
// gate covers only the 2..6-token window that reaches here.
std.debug.assert(tokens.len >= 1); // operator token always present
if (tokens.len != 1) return self.abort();
self.private_pending = true; // r1/r3 — a repeat leaves it set
return payload_start; // arg-less card carries no byte payload
},
// §5 — out-of-scope-but-legal reply cards: a strict fail-safe
// boundary, never `unreachable` on legal input, never a wrong answer.
.uvfile, .uvigot, .uvgimme => return ConsumeError.NotImplemented,
}
}
/// §7.5 L2363-2364 — consume the pending private marker at file/cfile handler
/// entry: read it, clear it unconditionally, return the prior value. The
/// consume is before arity/hash validation (L2432-2434 states this for the
/// server, and the receive rule is direction-symmetric), so a later-rejected
/// card still clears the marker and never binds a subsequent card.
fn takePrivateMarker(self: *Client) bool {
const was_pending = self.private_pending;
self.private_pending = false;
std.debug.assert(!self.private_pending); // negative space: always clear after
return was_pending;
}
/// §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;
}
};
/// §10.1 r6/r11 — the two version-pragma names `consumeReply` records the peer
/// version from; every other pragma name is tolerated and ignored (§3.5).
fn isVersionPragmaName(name: []const u8) bool {
return std.mem.eql(u8, name, "server-version") or std.mem.eql(u8, name, "client-version");
}
// ---------------------------------------------------------------------------
// Section 5 — Card Format
// ---------------------------------------------------------------------------
/// §5.2 — card/comment line bound, measured from the first line byte
/// through and including the terminating LF, excluding any following
/// byte-counted payload.
pub const max_line_len: usize = 65535;
/// §5.2 — current Fossil implementations expose at most the first six
/// parsed tokens to the per-card handler; later tokens are surplus.
pub const max_tokens: usize = 6;
/// §5.2.1, §5-r20 — the complete card operator catalog. `parseOperator`
/// resolves a token against this set by exact field-name match, so the
/// catalog itself is the lexical gate for §5-r6 (lowercase-plus-
/// underscore operator tokens): anything else simply has no field.
pub const Operator = enum {
cookie,
login,
push,
pull,
clone,
file,
cfile,
uvfile,
private,
igot,
uvigot,
gimme,
uvgimme,
reqconfig,
config,
clone_seqno,
pragma,
@"error",
message,
};
comptime {
// §5-r20 — Section 5.2.1 lists 4, the remaining-operators sentence
// lists 15; 4 + 15 = 19.
std.debug.assert(@typeInfo(Operator).@"enum".fields.len == 19);
}
/// Which end of the exchange is classifying the card (§5-r17: the same
/// disposition is fatal for the client and non-fatal for the server).
pub const Side = enum { client, server };
/// §5.2 outcome of classifying one card line.
pub const Disposition = enum { dispatch, unknown_operator, malformed_content, bad_command };
/// §5.1 — one LF-delimited line. `bytes` excludes the LF; `next` is the
/// body offset immediately after it, where a following byte-counted
/// payload (if any) begins.
pub const Line = struct {
bytes: []const u8,
next: usize,
};
/// §5-r11 — the sole line-framing failure: no LF within the bound.
pub const LineError = error{MissingTerminator};
/// §5.2 tokenization result: up to `max_tokens` slots into the source
/// line, plus a flag for tokens beyond that window (§5-r14).
pub const Tokens = struct {
items: [max_tokens][]const u8 = undefined,
len: usize = 0,
surplus: bool = false,
};
/// §5.2 classification of a card line: its tokens, the resolved
/// operator (null when unrecognized), and the disposition that follows.
pub const CardClass = struct {
tokens: Tokens,
operator: ?Operator,
disposition: Disposition,
};
/// §5.3, App. A.4, App. B.3 — a parsed `# timestamp DATETIME errors N`
/// comment subform.
pub const TimestampComment = struct {
datetime: []const u8,
errors: u64,
};
/// §5.1, §5-r10/r11 — find the next LF-terminated line starting at
/// `start`. The LF must fall within `max_line_len` bytes of `start`
/// (bound counted through-and-including the LF); a following byte-
/// counted payload is never scanned (§5-r3) — the caller advances past
/// it using `next + declared_count`.
pub fn readLine(body: []const u8, start: usize) LineError!Line {
std.debug.assert(start <= body.len);
const window_end = @min(body.len, start + max_line_len);
var i: usize = start;
while (i < window_end) : (i += 1) {
if (body[i] == '\n') {
const line: Line = .{ .bytes = body[start..i], .next = i + 1 };
std.debug.assert(line.bytes.len <= max_line_len - 1);
std.debug.assert(line.next <= body.len);
return line;
}
}
return LineError.MissingTerminator;
}
/// §5.2, §5-r7 — Fossil whitespace: SP, HTAB, VT, FF, CR. LF is
/// deliberately excluded: it terminates the card line and is never a
/// token separator.
pub fn isFossilWhitespace(b: u8) bool {
return switch (b) {
' ', '\t', 0x0b, 0x0c, '\r' => true,
else => false,
};
}
/// §5.2, §5-r4/r5/r7/r8/r9/r14 — tokenize a card line (LF already
/// stripped by `readLine`) on runs of Fossil whitespace. The canonical
/// single-space wire form and the compatibility multi-run extension
/// produce identical tokens. A token past the sixth sets `surplus` and
/// is not stored (Fossil's fixed six-slot handler window).
pub fn tokenizeCard(line: []const u8) Tokens {
std.debug.assert(line.len <= max_line_len - 1);
std.debug.assert(std.mem.indexOfScalar(u8, line, '\n') == null);
var result: Tokens = .{};
var i: usize = 0;
while (i < line.len) {
if (isFossilWhitespace(line[i])) {
i += 1;
continue;
}
const start = i;
while (i < line.len and !isFossilWhitespace(line[i])) : (i += 1) {}
const token = line[start..i];
if (result.len < max_tokens) {
result.items[result.len] = token;
result.len += 1;
} else {
result.surplus = true;
}
}
std.debug.assert(result.len <= max_tokens);
std.debug.assert(!result.surplus or result.len == max_tokens);
return result;
}
/// §5.2, §5-r6/r12 — resolve an operator token against the catalog.
/// `std.meta.stringToEnum` only matches an exact field-name string, so
/// an uppercase letter, a stray digit, or a misplaced underscore falls
/// through to null (unknown operator) without a separate lexical check.
pub fn parseOperator(token: []const u8) ?Operator {
return std.meta.stringToEnum(Operator, token);
}
/// §5-r16 — the three payload-bearing card types, whose arity failure
/// aborts with `malformed CARD line` rather than `bad command:`.
pub fn isContentBearing(op: Operator) bool {
return switch (op) {
.file, .cfile, .uvfile => true,
else => false,
};
}
/// §5.2 dispatch gate: tokenize once, then apply §5-r12 (unknown first
/// token) and §5-r14/r15/r16/r17 (surplus-token disposition, split on
/// content-bearing vs. simple operators). Per-operator arity within the
/// six-token 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) {
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 is
// the only outcome that carries a resolved operator with no
// surplus tokens; every other outcome fails one of those two.
std.debug.assert((result.disposition == .dispatch) ==
(result.operator != null and !result.tokens.surplus));
std.debug.assert((result.disposition == .unknown_operator) == (result.operator == null));
return result;
}
/// §5-r12/r16/r17, Table 3 — unknown operator and malformed content are
/// fatal on either side. Bad command is fatal only when the client
/// detects it; on the server it is non-fatal (§5-r17).
pub fn isFatal(d: Disposition, side: Side) bool {
return switch (d) {
.unknown_operator, .malformed_content => true,
.bad_command => side == .client,
.dispatch => false,
};
}
/// §5-r17/r18, App. C — the sole unrecognized-card wire text: exactly
/// `"bad command: "` followed by the offending line. There is no
/// "Unknown message" text anywhere in the protocol.
pub fn writeBadCommand(line: []const u8, out: []u8) []u8 {
const prefix = "bad command: ";
std.debug.assert(out.len >= prefix.len + line.len);
@memcpy(out[0..prefix.len], prefix);
@memcpy(out[prefix.len .. prefix.len + line.len], line);
return out[0 .. prefix.len + line.len];
}
/// §5-r16, App. C — `malformed CARD line`, where CARD is the operator
/// name (the same uppercase-metavariable convention as LINE in
/// §5-r17), e.g. `malformed file line`.
pub fn writeMalformedCard(op: Operator, out: []u8) []u8 {
std.debug.assert(isContentBearing(op));
const name = @tagName(op);
const prefix = "malformed ";
const suffix = " line";
const needed = prefix.len + name.len + suffix.len;
std.debug.assert(out.len >= needed);
var i: usize = 0;
@memcpy(out[i .. i + prefix.len], prefix);
i += prefix.len;
@memcpy(out[i .. i + name.len], name);
i += name.len;
@memcpy(out[i .. i + suffix.len], suffix);
i += suffix.len;
return out[0..i];
}
/// §5.3, §5-r21 — a comment line is any line whose first byte is
/// ASCII `#` (0x23).
pub fn isCommentLine(line: []const u8) bool {
return line.len > 0 and line[0] == '#';
}
/// §5.3, §5-r23/r24/r25, App. A.4, App. B.3 — parse the
/// `# timestamp DATETIME errors N` subform out of a comment line. Any
/// other shape (nonce comment, `# sending-clusters`, a non-numeric N)
/// yields null: those lines are not semantically interpreted (§5-r25).
pub fn parseTimestampComment(line: []const u8) ?TimestampComment {
std.debug.assert(isCommentLine(line));
const tokens = tokenizeCard(line);
if (tokens.len != 5 or tokens.surplus) return null;
if (!std.mem.eql(u8, tokens.items[0], "#")) return null;
if (!std.mem.eql(u8, tokens.items[1], "timestamp")) return null;
if (!std.mem.eql(u8, tokens.items[3], "errors")) return null;
const n = std.fmt.parseInt(u64, tokens.items[4], 10) catch return null;
return .{ .datetime = tokens.items[2], .errors = n };
}
// ---------------------------------------------------------------------------
// Section 10 — Pragmas
// ---------------------------------------------------------------------------
/// §10 L2287-2291 — the complete pragma name catalog: nine client-to-
/// server names followed by five server-to-client names, in the RFC's
/// own listing order.
pub const PragmaName = enum {
@"send-private",
@"send-catalog",
@"client-version",
@"uv-hash",
@"ci-lock",
@"ci-unlock",
@"client-url",
@"req-links",
@"req-clusters",
@"server-version",
@"uv-push-ok",
@"uv-pull-only",
@"ci-lock-fail",
@"avoid-delta-manifests",
};
comptime {
// §10 L2287 — "Fourteen pragma names are defined."
std.debug.assert(@typeInfo(PragmaName).@"enum".fields.len == 14);
}
/// §10 L2287-2291 — which side originates a pragma.
pub const PragmaDirection = enum { client_to_server, server_to_client };
/// §10 L2287-2291 — direction table, split exactly as the RFC lists it.
pub fn pragmaDirection(name: PragmaName) PragmaDirection {
return switch (name) {
.@"send-private",
.@"send-catalog",
.@"client-version",
.@"uv-hash",
.@"ci-lock",
.@"ci-unlock",
.@"client-url",
.@"req-links",
.@"req-clusters",
=> .client_to_server,
.@"server-version",
.@"uv-push-ok",
.@"uv-pull-only",
.@"ci-lock-fail",
.@"avoid-delta-manifests",
=> .server_to_client,
};
}
comptime {
// §10 L2287-2291 — "Nine are client-to-server ... Five are
// server-to-client."
var c2s: usize = 0;
var s2c: usize = 0;
for (@typeInfo(PragmaName).@"enum".fields) |f| {
switch (pragmaDirection(@enumFromInt(f.value))) {
.client_to_server => c2s += 1,
.server_to_client => s2c += 1,
}
}
std.debug.assert(c2s == 9 and s2c == 5);
}
/// §10 r2/r3/r5 L2281-2283 — resolve a pragma name token. Unrecognised
/// tokens fall through to null via the same exact-field-name match
/// `parseOperator` uses for card operators; this IS the forward-
/// compatibility mechanism, not a special case of it.
pub fn parsePragmaName(token: []const u8) ?PragmaName {
return std.meta.stringToEnum(PragmaName, token);
}
/// §10 L2279 result of classifying a `pragma` card: the resolved name
/// (null when the receiver does not recognise it, §10 r2) plus the raw
/// tokens for the caller's own per-pragma argument parsing.
pub const PragmaClass = struct { name: ?PragmaName, tokens: Tokens };
/// §10 r1 L2279 — a pragma card is `pragma NAME [ARGUMENTS]`. Runs only
/// after `classifyCard` has already dispatched the line as `.pragma`
/// with no surplus tokens (§5); this function does not re-derive that.
pub fn classifyPragma(tokens: Tokens) PragmaClass {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
std.debug.assert(!tokens.surplus);
const name: ?PragmaName = if (tokens.len >= 2) parsePragmaName(tokens.items[1]) else null;
return .{ .name = name, .tokens = tokens };
}
/// §10.1 L2310-2312 — DATE is YYYYMMDD, TIME is HHMMSS; a pragma carries
/// both together or neither (L2312, L2320-2321), so this is one
/// both-or-neither optional on `VersionInfo`/`Peer`, never two fields.
pub const DateTime = struct { date: u32, time: u32 };
/// §10.1 — one parsed wire shape shared by both version pragmas
/// (client-version and server-version carry the same VERSION [DATE
/// TIME] grammar, L2306/L2319-2320).
pub const VersionInfo = struct { version: u32, datetime: ?DateTime = null };
/// §3.1 L353-357 endpoint state entity ("peer version" / "peer date")
/// kept deliberately distinct from `VersionInfo`: `VersionInfo` is the
/// parsed wire payload of one pragma, `Peer` is the carried per-session
/// record with a role-specific pre-pragma default (server seeds VERSION
/// 0 per r10 L2317-2319; client seeds its own release integer per r14
/// L2326-2328). Same fields, different lifecycle and default — merging
/// them would erase which constructor supplied the seed.
pub const Peer = struct {
version: u32,
datetime: ?DateTime = null,
/// §10.1 r10 L2317-2319 — server's peer record before any
/// client-version pragma has been received.
pub fn serverInit() Peer {
return .{ .version = 0 };
}
/// §10.1 r14 L2326-2328 — client's peer record before any
/// server-version pragma has been received: its own release
/// integer.
pub fn clientInit(own_version: u32) Peer {
return .{ .version = own_version };
}
};
/// §10.1 r9 L2316-2317 (server records client) and r13 L2325-2326
/// (client records server) — the recording action is byte-for-byte
/// identical on both sides, so one function serves both; only the
/// pre-record default differs, and that lives in the two `Peer`
/// constructors above, not here.
pub fn recordPeerVersion(p: *Peer, v: VersionInfo) void {
p.version = v.version;
p.datetime = v.datetime;
std.debug.assert(p.version == v.version and std.meta.eql(p.datetime, v.datetime));
}
/// §10.1 r6 L2303-2304 — client-version is mandatory on every request;
/// a missing or non-decimal VERSION is a real parse failure, not a
/// modelled-away optional.
pub const PragmaError = error{Malformed};
/// §10.1 r11 L2321 — "at least five tokens total" (pragma, NAME,
/// VERSION, DATE, TIME) is the sole gate for DATE/TIME presence.
pub const min_datetime_tokens: usize = 5;
/// §10.1 r7 L2308-2310 — VERSION = MAJOR*10000 + MINOR*100 + PATCH.
/// MINOR and PATCH are two-digit fields; MAJOR is bounded so the sum
/// cannot overflow the u32 result.
pub fn encodeVersion(major: u32, minor: u32, patch: u32) u32 {
std.debug.assert(minor < 100 and patch < 100);
const max_major = (std.math.maxInt(u32) - 9999) / 10000;
std.debug.assert(major <= max_major);
return major * 10000 + minor * 100 + patch;
}
/// §10.1 r6/r8/r11 L2303-2323 — parse `pragma {client,server}-version
/// VERSION [DATE TIME]`. VERSION is `items[2]` and mandatory (r6);
/// DATE/TIME are `items[3]`/`items[4]`, present iff the card has at
/// least `min_datetime_tokens` tokens (r11).
pub fn parseVersionPragma(tokens: Tokens) PragmaError!VersionInfo {
if (tokens.len < 3) return PragmaError.Malformed;
const version = std.fmt.parseInt(u32, tokens.items[2], 10) catch return PragmaError.Malformed;
var datetime: ?DateTime = null;
if (tokens.len >= min_datetime_tokens) {
const date = std.fmt.parseInt(u32, tokens.items[3], 10) catch return PragmaError.Malformed;
const time = std.fmt.parseInt(u32, tokens.items[4], 10) catch return PragmaError.Malformed;
datetime = .{ .date = date, .time = time };
}
const result: VersionInfo = .{ .version = version, .datetime = datetime };
std.debug.assert((result.datetime == null) == (tokens.len < min_datetime_tokens));
return result;
}
/// §10.1 r11/r12 L2319-2323 — the server replies with server-version
/// exactly when the received client-version pragma carried DATE and
/// TIME.
pub fn serverEmitsServerVersion(v: VersionInfo) bool {
return v.datetime != null;
}
/// §10.1 r6/r8 L2303-2313 — write `pragma client-version VERSION [DATE
/// TIME]`: full form when `v.datetime` is set, VERSION-only otherwise.
/// DATE/TIME are zero-padded fixed-width YYYYMMDD/HHMMSS (L2310-2312
/// specifies positional *forms*, which requires leading zeros).
pub fn writeClientVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 {
if (v.datetime) |dt| {
std.debug.assert(dt.date < 100000000 and dt.time < 1000000);
const needed = std.fmt.count("pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
}
const needed = std.fmt.count("pragma client-version {d}", .{v.version});
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma client-version {d}", .{v.version});
}
/// §10.1 r11 L2319-2323 — write `pragma server-version VERSION DATE
/// TIME`. Always the full form (the server never emits it without
/// DATE/TIME, r12); same zero-padded fixed-width form as
/// `writeClientVersion`.
pub fn writeServerVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(v.datetime != null);
const dt = v.datetime.?;
std.debug.assert(dt.date < 100000000 and dt.time < 1000000);
const needed = std.fmt.count("pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
}
/// §10.2 r15 L2333 — `pragma uv-hash HASH`; HASH is `items[2]`.
pub fn parseUvHash(tokens: Tokens) ?[]const u8 {
return if (tokens.len >= 3) tokens.items[2] else null;
}
/// §8.4 — a request class deferred on clone's first round: the client
/// suppresses login, config exchange, and uv-hash while `op == .clone`
/// and `round == 0`. One definition shared by every deferred pragma
/// (§10.2 r16, §10.3 r22) rather than each re-deriving the predicate.
/// §8.3 L1988-1993 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. The request-assembly call site
/// that consults it for login gating is not yet written; only the
/// uv-hash call site (above) exists so far.
pub fn sendableAfterCloneRound0(op: Operation, round: u64) bool {
return !(op == .clone and round == 0);
}
/// §10.2 r16 L2335-2337, §9.1.1 L2058-2059 — uv-hash goes out at most
/// once per session and never on clone round 0.
pub fn clientMaySendUvHash(hash_sent: bool, op: Operation, round: u64) bool {
return !hash_sent and sendableAfterCloneRound0(op, round);
}
/// §10.2 r17/r18 L2339-2343 — the server's per-request unversioned-file
/// response.
pub const UvResponse = enum { none, push_ok, pull_only };
/// §10.2 r17/r18 L2339-2343, §9.1.1 L2077-2081 — matching catalog hash
/// or missing read permission yields no response; otherwise the server
/// grants push or pull-only depending on upload permission.
pub fn serverUvResponse(match: bool, has_read: bool, may_upload: bool) UvResponse {
const result: UvResponse = blk: {
if (match) break :blk .none;
if (!has_read) break :blk .none;
break :blk if (may_upload) .push_ok else .pull_only;
};
std.debug.assert(result != .push_ok or (!match and has_read and may_upload));
std.debug.assert(result != .pull_only or (!match and has_read and !may_upload));
std.debug.assert(result != .none or (match or !has_read));
return result;
}
/// §9.1.1 L2088-2091, §10.2 L2359-2361 — the client's per-session
/// unversioned-upload state as a single enum, not two independent
/// bools: the r19 pull-only latch is one field and the contradictory
/// "push-ok AND pull-only" state is unrepresentable.
pub const UvUpload = enum { undetermined, push_ok, pull_only };
/// §3.1 L374-399 — mirrors the "catalog sent" flag and the collapsed
/// upload latch [rw-1] as the client's per-session unversioned state.
pub const UvSession = struct {
hash_sent: bool = false,
upload: UvUpload = .undetermined,
};
/// §9.1.1 L2088-2089 — pragma uv-push-ok sets upload-enabled, but MUST
/// NOT override an existing pull-only latch (r19, §10.2 L2359-2361):
/// once pull-only, a later push-ok is a no-op.
pub fn clientOnUvPushOk(s: *UvSession) void {
const old = s.upload;
if (s.upload != .pull_only) s.upload = .push_ok;
std.debug.assert(if (old == .pull_only) s.upload == .pull_only else s.upload == .push_ok);
}
/// §9.1.1 L2089-2091, §10.2 r19 L2359-2361 — pragma uv-pull-only
/// latches the session unconditionally; it always wins.
pub fn clientOnUvPullOnly(s: *UvSession) void {
s.upload = .pull_only;
std.debug.assert(s.upload == .pull_only);
}
// §3.5 — Request and Response Pragmas (pointer clause into §10; establishes
// only that pragmas dispatch in round order and the unknown-name/unknown-
// operator split, both realized by the existing §5/§10 primitives).
// ---------------------------------------------------------------------------
// Section 10.3 — Configuration Exchange
// ---------------------------------------------------------------------------
/// §5.2 — tokenization splits a card on Fossil-whitespace runs, so an
/// unquoted wire arg (group name, pragma arg) that embeds one would
/// silently split into two tokens on the wire; shared by every writer
/// below that formats a raw token straight onto the wire.
pub fn containsFossilWhitespace(s: []const u8) bool {
for (s) |b| {
if (isFossilWhitespace(b)) return true;
}
return false;
}
/// §10.3 L2381-2383 — the scalar-settings marker: a fixed GROUPNAME
/// that carries every scalar repository setting regardless of which
/// group the client actually requested (r24, r27).
pub const config_scalar_marker: []const u8 = "/config";
/// §10.3 L2381-2404 — a config card's payload is either the fixed
/// scalar marker or a table GROUPNAME (r24 vs. r28).
pub const ConfigKind = enum { scalar, table };
/// §10.3 r24/r28 — classify a GROUPNAME by exact match against the
/// scalar marker; anything else is a table-type group name.
pub fn configKind(groupname: []const u8) ConfigKind {
return if (std.mem.eql(u8, groupname, config_scalar_marker)) .scalar else .table;
}
/// §10.3 r20 L2369-2370 — the reqconfig NAME MUST-begin-with-/ gate.
pub fn configNameHasSlashPrefix(name: []const u8) bool {
return name.len >= 1 and name[0] == '/';
}
/// §10.3 r20 — write `reqconfig NAME`.
pub fn writeReqconfig(name: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(configNameHasSlashPrefix(name));
std.debug.assert(!containsFossilWhitespace(name));
const needed = std.fmt.count("reqconfig {s}", .{name});
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "reqconfig {s}", .{name});
}
/// §10.3 r20/r21 — the requested group name off a dispatched reqconfig
/// card; null when the card carries no NAME token.
pub fn parseReqconfigName(tokens: Tokens) ?[]const u8 {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .reqconfig);
return if (tokens.len >= 2) tokens.items[1] else null;
}
/// §10.3 r21 L2370-2372 — the server's reqconfig disposition: `.ignore`
/// emits no error card at all, unlike every fatal disposition in §5.
pub const ReqconfigDisposition = enum { reply, ignore };
/// §10.3 r21 — Read permission is the sole gate; unauthorized is a
/// silent no-op, not a rejection card.
pub fn serverReqconfigDisposition(has_read: bool) ReqconfigDisposition {
return if (has_read) .reply else .ignore;
}
/// §10.3 r23 L2377-2378 — a parsed `config GROUPNAME SIZE` card header.
pub const ConfigCard = struct { groupname: []const u8, size: u32 };
/// §10.3 r32 L2430-2431 — a negative SIZE is distinguished from an
/// ordinary parse failure because it names a specific fatal action
/// (abort the transaction) rather than an unspecified malformed card.
pub const ConfigError = error{ Malformed, NegativeSize };
/// §10.3 r23/r32 — parse `config GROUPNAME SIZE`. SIZE is read as a
/// signed field so a negative literal is representable and reported as
/// `NegativeSize` rather than folded into `Malformed`; a positive value
/// exceeding `u32` is rejected outright, never silently truncated.
pub fn parseConfigCard(tokens: Tokens) ConfigError!ConfigCard {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .config);
if (tokens.len < 3) return ConfigError.Malformed;
const raw = std.fmt.parseInt(i64, tokens.items[2], 10) catch return ConfigError.Malformed;
if (raw < 0) return ConfigError.NegativeSize;
if (raw > std.math.maxInt(u32)) return ConfigError.Malformed;
const size: u32 = @intCast(raw);
return .{ .groupname = tokens.items[1], .size = size };
}
/// §10.3 r23 — write `config GROUPNAME SIZE`.
pub fn writeConfigCard(groupname: []const u8, size: u32, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(!containsFossilWhitespace(groupname));
const needed = std.fmt.count("config {s} {d}", .{ groupname, size });
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "config {s} {d}", .{ groupname, size });
}
/// §10.3 r23/r29/r33 — the SIZE content bytes plus the offset just past
/// the mandatory trailing newline.
pub const ConfigPayload = struct { bytes: []const u8, next: usize };
/// §10.3 r29 L2419-2421 — the two ways a config payload frame fails:
/// the declared span (or its trailing newline byte) runs past the
/// buffer, or the byte immediately after SIZE bytes is not the LF r29
/// mandates.
pub const ConfigFrameError = error{ Truncated, MissingNewline };
/// §10.3 r23/r29/r33 — slice out the SIZE content bytes starting at
/// `start` and consume the single mandatory trailing newline.
/// Overflow-safe: `start + size + 1` is computed with checked addition
/// so an adversarial SIZE cannot wrap `usize` into passing the bound
/// check.
pub fn consumeConfigPayload(body: []const u8, start: usize, size: u32) ConfigFrameError!ConfigPayload {
std.debug.assert(start <= body.len);
const end = std.math.add(usize, start, size) catch return ConfigFrameError.Truncated;
const next = std.math.add(usize, end, 1) catch return ConfigFrameError.Truncated;
if (next > body.len) return ConfigFrameError.Truncated;
if (body[end] != '\n') return ConfigFrameError.MissingNewline;
const result: ConfigPayload = .{ .bytes = body[start..end], .next = next };
std.debug.assert(result.next == start + @as(usize, size) + 1);
std.debug.assert(body[start + @as(usize, size)] == '\n');
return result;
}
/// §10.3 r26 L2391-2393 — wrap `raw` in Fossil quote encoding: enclosing
/// single quotes, every interior single quote doubled (SQL-style
/// string-literal escaping).
pub fn fossilQuote(raw: []const u8, out: []u8) []u8 {
std.debug.assert(out.len >= 2 + 2 * raw.len);
var i: usize = 0;
out[i] = '\'';
i += 1;
for (raw) |b| {
out[i] = b;
i += 1;
if (b == '\'') {
out[i] = '\'';
i += 1;
}
}
out[i] = '\'';
i += 1;
const result = out[0..i];
std.debug.assert(result.len >= 2 and result[0] == '\'' and result[result.len - 1] == '\'');
std.debug.assert(result.len <= 2 + 2 * raw.len);
return result;
}
/// §10.3 r26 — a quoted token missing its enclosing quotes, or with an
/// interior single quote that is not doubled, is malformed.
pub const QuoteError = error{Malformed};
/// §10.3 r26 L2393-2395 — undo `fossilQuote`: strip the enclosing
/// quotes and undouble every interior `''` back to `'`. A single pass
/// bounded by `quoted.len`.
pub fn fossilUnquote(quoted: []const u8, out: []u8) QuoteError![]const u8 {
if (quoted.len < 2 or quoted[0] != '\'' or quoted[quoted.len - 1] != '\'') return QuoteError.Malformed;
std.debug.assert(out.len >= quoted.len - 2);
const inner_end = quoted.len - 1;
var i: usize = 1;
var j: usize = 0;
while (i < inner_end) {
if (quoted[i] == '\'') {
if (i + 1 < inner_end and quoted[i + 1] == '\'') {
out[j] = '\'';
j += 1;
i += 2;
} else {
return QuoteError.Malformed;
}
} else {
out[j] = quoted[i];
j += 1;
i += 1;
}
}
const result = out[0..j];
std.debug.assert(result.len <= quoted.len - 2);
return result;
}
/// §10.3 r25 L2386-2390 — the scalar payload's two quoted fields, still
/// in their wire-quoted form (unquoting is the caller's explicit
/// `fossilUnquote` step, so this parse stays allocation-free).
pub const ScalarConfig = struct { mtime: u64, quoted_name: []const u8, quoted_value: []const u8 };
/// §10.3 r25/r26 — scan one Fossil-quoted token starting at `payload[start]`
/// (which must be `'`); returns the index just past its closing quote,
/// or null if the payload ends before the token closes. Bounded by
/// `payload.len`.
fn scanQuoted(payload: []const u8, start: usize) ?usize {
std.debug.assert(start < payload.len);
std.debug.assert(payload[start] == '\'');
var i: usize = start + 1;
while (i < payload.len) {
if (payload[i] == '\'') {
if (i + 1 < payload.len and payload[i + 1] == '\'') {
i += 2;
continue;
}
return i + 1;
}
i += 1;
}
return null;
}
/// §10.3 r25 L2386-2390 — parse `MTIME SP QUOTED-NAME SP value SP
/// QUOTED-VALUE`: the literal token `value` appears verbatim between
/// the two quoted fields. A single left-to-right pass bounded by
/// `payload.len`; any structural miss is `Malformed`.
pub fn parseScalarConfig(payload: []const u8) error{Malformed}!ScalarConfig {
const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed;
const mtime = std.fmt.parseInt(u64, payload[0..sp1], 10) catch return error.Malformed;
var i = sp1 + 1;
if (i >= payload.len or payload[i] != '\'') return error.Malformed;
const name_end = scanQuoted(payload, i) orelse return error.Malformed;
const quoted_name = payload[i..name_end];
i = name_end;
if (i >= payload.len or payload[i] != ' ') return error.Malformed;
i += 1;
const literal = "value";
if (i + literal.len > payload.len or !std.mem.eql(u8, payload[i .. i + literal.len], literal)) return error.Malformed;
i += literal.len;
if (i >= payload.len or payload[i] != ' ') return error.Malformed;
i += 1;
if (i >= payload.len or payload[i] != '\'') return error.Malformed;
const value_end = scanQuoted(payload, i) orelse return error.Malformed;
if (value_end != payload.len) return error.Malformed;
const result: ScalarConfig = .{ .mtime = mtime, .quoted_name = quoted_name, .quoted_value = payload[i..value_end] };
std.debug.assert(result.quoted_name.len >= 2 and result.quoted_name[0] == '\'' and result.quoted_name[result.quoted_name.len - 1] == '\'');
std.debug.assert(result.quoted_value.len >= 2 and result.quoted_value[0] == '\'' and result.quoted_value[result.quoted_value.len - 1] == '\'');
return result;
}
/// §10.3 r25/r26 — assemble `MTIME SP 'NAME' SP value SP 'VALUE'`,
/// quoting the raw name/value with `fossilQuote`.
pub fn writeScalarConfig(mtime: u64, raw_name: []const u8, raw_value: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
const mtime_len = std.fmt.count("{d}", .{mtime});
const name_bound = 2 + 2 * raw_name.len;
const value_bound = 2 + 2 * raw_value.len;
const literal = " value ";
const needed = mtime_len + 1 + name_bound + literal.len + value_bound;
std.debug.assert(out.len >= needed);
var i: usize = 0;
const mtime_str = try std.fmt.bufPrint(out[i..], "{d}", .{mtime});
i += mtime_str.len;
out[i] = ' ';
i += 1;
const name_q = fossilQuote(raw_name, out[i .. i + name_bound]);
i += name_q.len;
@memcpy(out[i .. i + literal.len], literal);
i += literal.len;
const value_q = fossilQuote(raw_value, out[i .. i + value_bound]);
i += value_q.len;
const result = out[0..i];
std.debug.assert(result.len <= needed);
return result;
}
/// §10.3 r31 L2427-2429 — the exact error-card text for an
/// unauthorized pushed config card.
pub const config_push_unauthorized_text: []const u8 = "not authorized to push configuration";
/// §10.3 r31/r33 — the server's disposition of a pushed config card.
pub const ConfigPushDecision = enum { apply, reject };
/// §10.3 r31 — Admin capability is the sole gate for a pushed config
/// card; without it the card is rejected and not applied.
pub fn serverConfigPushDecision(has_admin: bool) ConfigPushDecision {
return if (has_admin) .apply else .reject;
}
// ---------------------------------------------------------------------------
// Section 10.4 — Commit-Lock Pragmas
// ---------------------------------------------------------------------------
/// §10.4 L2444-2445 — `pragma ci-lock HASH CLIENT-ID` arguments.
pub const CiLock = struct { hash: []const u8, client_id: []const u8 };
/// §10.4 — HASH/CLIENT-ID off a dispatched `pragma ci-lock` card.
pub fn parseCiLock(tokens: Tokens) ?CiLock {
std.debug.assert(tokens.len >= 2);
std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock");
if (tokens.len < 4) return null;
return .{ .hash = tokens.items[2], .client_id = tokens.items[3] };
}
/// §10.4 — write `pragma ci-lock HASH CLIENT-ID`.
pub fn writeCiLock(hash: []const u8, client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(hash.len > 0 and !containsFossilWhitespace(hash));
std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id));
const needed = std.fmt.count("pragma ci-lock {s} {s}", .{ hash, client_id });
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma ci-lock {s} {s}", .{ hash, client_id });
}
/// §10.4 L2446-2449 — the server's disposition of a `ci-lock` request:
/// granted when unheld or already held by the same requester, failed
/// when held by a different client.
pub const CiLockResult = enum { granted, failed };
/// §10.4 r36 — `holder` is the current lock holder's login, if any.
pub fn serverCiLockResult(holder: ?[]const u8, requester: []const u8) CiLockResult {
const result: CiLockResult = blk: {
const h = holder orelse break :blk .granted;
break :blk if (std.mem.eql(u8, h, requester)) .granted else .failed;
};
std.debug.assert(result != .failed or (holder != null and !std.mem.eql(u8, holder.?, requester)));
return result;
}
/// §10.4 L2531 — `pragma ci-lock-fail LOGIN MTIME` arguments.
pub const CiLockFail = struct { login: []const u8, mtime: u64 };
/// §10.4 L2538-2556 — LOGIN/MTIME off a dispatched `pragma ci-lock-fail`
/// card; null on a missing or non-decimal MTIME. A parsed fault does
/// not by itself abort the round (L2538-2542: the client records the
/// fault and continues processing the remainder of the round); it only
/// gates a later commit against the locked leaf, per
/// `commitMayProceedAfterCiLockFail`. A negative or non-integer MTIME
/// already fails to parse here and is null (L2542-2547: a receiver MAY
/// treat that as fatal; a non-negative MTIME MUST NOT be a wire error).
pub fn parseCiLockFail(tokens: Tokens) ?CiLockFail {
std.debug.assert(tokens.len >= 2);
std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock-fail");
if (tokens.len < 4) return null;
const mtime = std.fmt.parseInt(u64, tokens.items[3], 10) catch return null;
return .{ .login = tokens.items[2], .mtime = mtime };
}
/// §10.4 L2531 — write `pragma ci-lock-fail LOGIN MTIME`.
pub fn writeCiLockFail(login: []const u8, mtime: u64, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(login.len > 0 and !containsFossilWhitespace(login));
const needed = std.fmt.count("pragma ci-lock-fail {s} {d}", .{ login, mtime });
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma ci-lock-fail {s} {d}", .{ login, mtime });
}
/// §10.4 L2548-2556 — with a recorded ci-lock fault, a commit against
/// the same check-in MUST NOT complete if it would fork the locked
/// leaf, unless the user requested an override that removes the
/// fork-avoidance guard.
pub fn commitMayProceedAfterCiLockFail(
fault_recorded: bool,
would_create_fork: bool,
override_requested: bool,
) bool {
const result = !(fault_recorded and would_create_fork and !override_requested);
std.debug.assert(fault_recorded or result); // §10.4 L2548: no recorded fault -> commit proceeds
std.debug.assert(!override_requested or result); // §10.4 L2551-2553: an override always permits
return result;
}
/// §10.4 L2561 — CLIENT-ID off a dispatched `pragma ci-unlock` card.
pub fn parseCiUnlock(tokens: Tokens) ?[]const u8 {
std.debug.assert(tokens.len >= 2);
std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-unlock");
return if (tokens.len >= 3) tokens.items[2] else null;
}
/// §10.4 L2561 — write `pragma ci-unlock CLIENT-ID`.
pub fn writeCiUnlock(client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id));
const needed = std.fmt.count("pragma ci-unlock {s}", .{client_id});
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma ci-unlock {s}", .{client_id});
}
/// §10.4 L2561-2563 — a client with a known client identifier sends
/// ci-unlock in a later round (round > 0) when no lock is pending for
/// the current exchange.
pub fn clientSendsCiUnlock(has_client_id: bool, lock_pending: bool, round: u64) bool {
return has_client_id and !lock_pending and round > 0;
}
// ---------------------------------------------------------------------------
// Section 10.5 — Private-Content and Discovery Pragmas
// ---------------------------------------------------------------------------
/// ABNF App. B.7 L3566-3575 — the seven `"pragma" SP NAME LF` productions
/// that carry no argument (send-private, send-catalog, req-links,
/// req-clusters, avoid-delta-manifests, uv-push-ok, uv-pull-only). The
/// other seven names all carry `SP fossilized` / `SP decimal` / `SP
/// artifact-hash` after NAME. Derived from the ABNF, not from this
/// batch's callers, so `writeFlagPragma` stays the general arg-less
/// writer rather than one special-cased to §10.5.
pub fn pragmaIsArgless(name: PragmaName) bool {
return switch (name) {
.@"send-private",
.@"send-catalog",
.@"req-links",
.@"req-clusters",
.@"avoid-delta-manifests",
.@"uv-push-ok",
.@"uv-pull-only",
=> true,
.@"client-version",
.@"server-version",
.@"uv-hash",
.@"ci-lock",
.@"ci-lock-fail",
.@"ci-unlock",
.@"client-url",
=> false,
};
}
/// §10 ABNF — write `pragma NAME` for an arg-less pragma. `@tagName`
/// reproduces the wire token exactly: `PragmaName` fields are spelled to
/// match the wire names (`parsePragmaName` relies on the same identity).
/// Precondition `pragmaIsArgless(name)` — a name that carries an argument
/// is a caller bug, not a runtime error.
pub fn writeFlagPragma(name: PragmaName, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(pragmaIsArgless(name));
const tag = @tagName(name);
const needed = std.fmt.count("pragma {s}", .{tag});
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "pragma {s}", .{tag});
}
/// §10.5 L2474-2475 — the exact unauthorized-send-private error text.
pub const send_private_unauthorized_text = "not authorized to sync private content";
/// §10.5 L2472-2476 — the server's disposition of a `send-private`
/// pragma.
pub const SendPrivateDecision = enum { enable, reject };
/// §10.5 r41 — private-content transfer is enabled for the session only
/// when the requesting user holds the Private (x) capability; carries no
/// state of its own, so a repeated call with the same input always
/// yields the same decision (r43's idempotence).
pub fn serverSendPrivateDecision(has_private_x: bool) SendPrivateDecision {
return if (has_private_x) .enable else .reject;
}
/// §10.5 r43/r48 — the two per-pragma send schedules the RFC states in
/// §10.5. Partial by design: the RFC gives a schedule for exactly three
/// names (send-private, client-url, req-links); the other eleven are
/// RFC-silent, so this returns `null` for them rather than fabricating a
/// total table the spec never states.
pub const PragmaSchedule = enum { once_pre_loop, every_private_round };
/// §10.5 r43 L2477-2479 (send-private repeats every private-sync round)
/// vs. r48 L2488-2490 (client-url/req-links go once, before the round
/// loop).
pub fn pragmaSchedule(name: PragmaName) ?PragmaSchedule {
return switch (name) {
.@"send-private" => .every_private_round,
.@"client-url", .@"req-links" => .once_pre_loop,
else => null,
};
}
/// §10.5 r47, ABNF `client-url-pragma` L3570 — write `pragma client-url
/// <fossilized(url)>` in place: the fixed prefix goes straight into
/// `out`, then `artifact.encodeEscapes` fills the remainder — one
/// buffer, no scratch. Precondition `out.len >= prefix.len +
/// 2*url.len` (the encoder's own worst case), so unlike the sibling
/// `write*` helpers this never touches `std.fmt.bufPrint` and cannot
/// fail — no error union to force a `try` on an unreachable path.
pub fn writeClientUrl(url: []const u8, out: []u8) []u8 {
const prefix = "pragma client-url ";
std.debug.assert(out.len >= prefix.len + 2 * url.len);
@memcpy(out[0..prefix.len], prefix);
const encoded = artifact.encodeEscapes(url, out[prefix.len..]);
return out[0 .. prefix.len + encoded.len];
}
/// §10.5 r47 — read the URL back off a dispatched `pragma client-url`
/// card: `items[2]` decoded. Null on a short card (negative space);
/// never indexes past `tokens.len`.
pub fn parseClientUrl(tokens: Tokens, out: []u8) ?[]const u8 {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
if (tokens.len < 3) return null;
std.debug.assert(parsePragmaName(tokens.items[1]) == .@"client-url");
return artifact.decodeEscapes(tokens.items[2], out);
}
// ---------------------------------------------------------------------------
// Section 11 — Error and Message Cards
// ---------------------------------------------------------------------------
/// §11.1 r11 L2648-2650, App. A.2 decoded text L3211 — the exact decoded text
/// emitted when the SHA3-256 peer-version gate (§7.6) fires.
pub const peer_version_error_text = "Fossil version 2.0 or later required.";
/// §11.1/§11.2 — an error card is a fatal protocol signal (outside clone
/// round 0); a message card is a non-fatal advisory. TEXT is encoded
/// identically on both (§11.1 r2, §11.2 r2), so one writer/parser pair serves
/// both — only the leading operator word differs.
pub const SignalKind = enum { err, message };
/// §5.2 L1263-1272 (usage sites: §11.1 r1/r2, §11.2 r1/r2, §3.6 L703-707)
/// — write `OP TEXT`, TEXT fossilize-encoded in place. Shared body for
/// every single-text-argument card: `op` carries its own trailing SP, then
/// `artifact.encodeEscapes` fills the remainder — one buffer, no scratch.
/// Like `writeClientUrl`, this never touches `std.fmt.bufPrint` and cannot
/// fail, so it returns `[]u8` directly rather than an error union nothing
/// ever populates.
fn writeTextArgCard(op: []const u8, text: []const u8, out: []u8) []u8 {
std.debug.assert(out.len >= op.len + 2 * text.len);
@memcpy(out[0..op.len], op);
const encoded = artifact.encodeEscapes(text, out[op.len..]);
return out[0 .. op.len + encoded.len];
}
/// §11.1 r1/r2 L2604-2610, §11.2 r1/r2 L2654-2658 — write `error TEXT`
/// or `message TEXT`; the operator word is the only thing that varies.
pub fn writeSignalCard(kind: SignalKind, text: []const u8, out: []u8) []u8 {
const op: []const u8 = switch (kind) {
.err => "error ",
.message => "message ",
};
return writeTextArgCard(op, text, out);
}
/// §11.1 r2, §11.2 r2 — read TEXT back off a dispatched `error`/
/// `message` card: `items[1]` decoded. Null on a card with no TEXT
/// token (negative space); never indexes past `tokens.len`.
pub fn parseSignalText(tokens: Tokens, out: []u8) ?[]const u8 {
std.debug.assert(tokens.len >= 1);
if (tokens.len < 2) return null;
return artifact.decodeEscapes(tokens.items[1], out);
}
/// §11.1 r4/r10 — the client's disposition of a received `error` card.
pub const ClientErrorAction = enum { discard, terminate };
/// §11.1 r4 L2614-2616 (outside clone round 0: terminate) / r10 L2642-2644
/// (first round of a clone: silently discard) / r9 L2639-2640 (no HTTP-
/// status input in scope — the signal lives wholly in the message body).
/// Exhaustive over `ClientState`: only `clone_round0` discards.
pub fn clientOnErrorCard(state: ClientState) ClientErrorAction {
return switch (state) {
.clone_round0 => .discard,
.init, .exchange, .terminated, .aborted => .terminate,
};
}
/// §11.1 L2619-2629 — the fatal-error sites the RFC distinguishes by
/// whether the server resets prior partial output before emitting the
/// error card.
pub const ErrorSite = enum {
wrong_project,
auth_failure,
duplicate_login,
unknown_operator,
peer_version_gate,
unauthorized_send_private,
private_card,
};
/// §11.1 L2619-2629 — reset (discard prior partial output) vs. append
/// (extend the already-built response) before emitting the error card.
pub const ErrorReset = enum { reset, append };
/// §11.1 r6 L2619-2623 (wrong project, auth failure, duplicate login,
/// unknown operator -> reset) vs. r8 L2626-2629 (peer-version gate,
/// unauthorized send-private, private card -> append).
pub fn serverErrorReset(site: ErrorSite) ErrorReset {
return switch (site) {
.wrong_project, .auth_failure, .duplicate_login, .unknown_operator => .reset,
.peer_version_gate, .unauthorized_send_private, .private_card => .append,
};
}
/// §11.2 — the client's disposition of a received `message` card.
pub const MessageAction = enum { pull_only_silent, display_optional };
/// §11.2 r4 L2662-2665, ambiguities.md §11.2 Reading A (settled) — the
/// glob `pull only *` carries its separating space; the trigger is the
/// literal 10-byte prefix `"pull only "`, so a bare `"pull only"` (no
/// trailing content) does not match.
pub fn matchesPullOnly(decoded: []const u8) bool {
return std.mem.startsWith(u8, decoded, "pull only ");
}
/// §11.2 item 1/2 — the client's disposition of a received `message` card.
/// The pull-only suppression path (r4) is entered only when push intent is
/// *live* (`push_active`) AND the decoded payload matches `pull only *`; a
/// match while push is already clear (r14) or any non-matching payload (r9)
/// falls through to the advisory display path.
pub fn clientOnMessageCard(push_active: bool, decoded: []const u8) MessageAction {
return if (push_active and matchesPullOnly(decoded)) .pull_only_silent else .display_optional;
}
// ---------------------------------------------------------------------------
// Section 3.6 — Cookie Card
// ---------------------------------------------------------------------------
/// §3.6 L703, grammar `cookie-card = "cookie" SP fossilized LF` (App. B.4)
/// — the opaque TEXT is fossilize-encoded exactly like error/message TEXT.
/// One writer serves both the server's emit (L703) and the client's return
/// (L706-707): the card shape is identical either direction.
pub fn writeCookieCard(text: []const u8, out: []u8) []u8 {
return writeTextArgCard("cookie ", text, out);
}
/// §3.6 L704-705 — the client's single held sync cookie: storing a new
/// value replaces any previously held one ("replacing any previously held
/// cookie"). Client-only: the server keeps no cookie state of its own
/// (L709), so this type is never instantiated server-side. `buf` is bounded
/// by the §5.2 card-line ceiling (`max_line_len`) — a longer TEXT could
/// never have arrived as a card line in the first place.
pub const CookieStore = struct {
buf: [max_line_len]u8 = undefined,
len: usize = 0,
held: bool = false,
/// §3.6 L704-705 — overwrite the decoded opaque value, discarding
/// whatever was held before.
pub fn store(self: *CookieStore, text: []const u8) void {
std.debug.assert(text.len <= self.buf.len);
@memcpy(self.buf[0..text.len], text);
self.len = text.len;
self.held = true;
std.debug.assert(self.len == text.len and self.held);
}
/// §3.6 L704-705, §4.3 — store a wire cookie token: defossilize `encoded`
/// (a reply-body slice, disjoint from `buf`) straight into `buf`, then set
/// the length and held flag. Allocation-free: the decoded form never grows
/// past the encoded token (§4.3), and the token can be at most one card
/// line (`max_line_len`), so `buf` always fits it — the cookie OOM path is
/// defined out of existence rather than allocating a transient scratch.
pub fn storeFossilized(self: *CookieStore, encoded: []const u8) void {
std.debug.assert(encoded.len <= self.buf.len);
const decoded = artifact.decodeEscapes(encoded, &self.buf);
self.len = decoded.len;
self.held = true;
std.debug.assert(self.len <= encoded.len and self.held);
}
/// §3.6 L706-707/L712-713 — null when nothing has ever been stored, so
/// a client that never returns a cookie (L713) has nothing to send.
pub fn value(self: *const CookieStore) ?[]const u8 {
return if (self.held) self.buf[0..self.len] else null;
}
};
// ---------------------------------------------------------------------------
// Section 7.6 / 12.4 — SHA3-256 Peer-Version Gate
// ---------------------------------------------------------------------------
/// §7.6 L1784, §10.1 L2308-2309 — "The value 20000 corresponds to Fossil
/// release 2.0."
pub const sha3_min_peer_version: u32 = 20000;
/// §7.6 L1780-1800, §12.4 L2673-2678 — exactly two outcomes. No third
/// "downgrade" outcome exists: §12.4 L2676-2678 forbids any SHA1
/// substitution or re-identification, so the gate can never rewrite a
/// name — the two-variant enum forbids that structurally.
pub const GateDecision = enum { emit, refuse };
/// §7.6 L1780-1795 — refuse iff `hash` names a SHA3-256 artifact (64
/// lowercase hex, [FossilArtifact] §3.2) AND `peer_version` is less than
/// `sha3_min_peer_version`. Site-agnostic: the file/cfile/uvfile send
/// sites (§7.6) all share this one decision.
///
/// `peer_version` is the caller's already-recorded version-pragma integer
/// for the peer — this function is a PURE function of that integer; it
/// does not choose one for an unrecorded peer. Per §10.1 L2316-2327 the
/// unrecorded-peer default is SIDE-DEPENDENT (server: before any
/// client-version pragma, treats VERSION as 0 -> refuse; client: before
/// any server-version pragma, treats VERSION as its own release integer
/// -> emit for a SHA3-capable client), so selecting that default is the
/// caller's responsibility, deferred to the round-loop/content-transfer
/// work that owns the recorded-peer-version state. On `.refuse` the
/// caller emits `writeSignalCard(.err, peer_version_error_text)` at
/// `ErrorSite.peer_version_gate`.
pub fn sha3PeerVersionGate(peer_version: u32, hash: []const u8) GateDecision {
const is_sha3 = artifact.validateName(hash) and hash.len == 64;
const d: GateDecision = if (is_sha3 and peer_version < sha3_min_peer_version) .refuse else .emit;
// Downgrade-safety invariant (§12.4 L2676-2678): a non-64-char name —
// every SHA1 name — is never refused, so the gate has no path that
// could truncate or re-identify. Derived from hash.len, not a
// restatement of the branch above.
std.debug.assert(d == .emit or hash.len == 64);
return d;
}
// §7.6 r7: the 2-field GateDecision enum is a precondition value, not a
// card variant — the gate defines no new card syntax, only a yes/no on
// whether the caller may write the card it already knows how to write.
// §7.6 r6: the gate applies at three independent send sites (file, cfile,
// uvfile) and a sender MUST apply it at each site independently. The gate
// itself is site-agnostic by design (L1782 folds the uvfile "64-character
// lowercase hexadecimal content hash" into the same HASH-shape test as the
// file/cfile name), so at the decision layer "independent per site" reduces
// to: the same pure function fires identically for three distinct 64-hex
// values, standing in for the file-name, cfile-name, and uvfile-hash forms.
// The three real call sites land with the future file/cfile/uvfile send
// batches (§7.1/§7.2/§7.3); this pins the decision those sites will share.
// App. A.2 L3102-3114: the hand-transcribed 4-line gate-fire response body
// (Content-Length: 191), cited not machine-generated. zit owns no
// clone_seqno/# timestamp writer or multi-card response assembler (out of
// §7.6 scope), so this only pins the fixture's byte length and that the one
// card §7.6 owns -- the error card -- is byte-identical to the body's first
// line. Decoded-text/encoding equality is not re-asserted here (that's §11
// r8); this test is about the body shape, not the text content.
// ---------------------------------------------------------------------------
// Section 3.7 — Round Continuation
// ---------------------------------------------------------------------------
/// §3.7 — carried post-round observations (one struct, not loose args), so
/// `continueExchange` stays a pure total function of "what happened last
/// round."
pub const RoundObservation = struct {
operation: Operation,
// (a) r2 — non-clone versioned progress into a non-empty phantom table.
versioned_content_delivered: bool = false,
phantom_created: bool = false,
phantom_table_nonempty: bool = false,
// (b) r3 — the per-round file-send count is greater than zero: at least
// one full-content card was appended to the send buffer while processing
// this reply (§3.7(b) L939-945). Per-round, not the persistent buffer.
content_queued: bool = false,
// (c) r4 — private-content discovery round pending.
private_discovery_pending: bool = false,
// (d) r5/r6 — unversioned send/gimme cycle.
uv_send_table_nonempty: bool = false,
uv_upload_enabled: bool = false,
uvgimme_queued: bool = false,
uvfile_received: bool = false,
session_round: u64 = 0, // round count AFTER incrementing for the completed round
// (e) r7 — modern-clone continuation.
first_clone_round: bool = false,
artifact_received_this_round: bool = false,
clone_seqno_next: i64 = 0, // received NEXT; negatives are the fatal path's job (§8.2)
artifact_count_grew: bool = false,
// (f) r8 — pending configuration exchange.
config_exchange_pending: bool = false,
// §3.6/§3.9 — a cookie card was accepted and stored this round. A pure
// observation: the cookie is "an optimisation only" (§3.6 L922), so this
// feeds no §3.7 continuation condition.
cookie_stored: bool = false,
// §7.5 L2359-2360 — a content card bound a pending private marker this round.
// Pure observation; the store/discard treatment (§7.5 L2381-2385) is a later
// batch, so this feeds no §3.7 continuation condition.
private_content_delivered: bool = false,
};
/// §3.7 r2, L735-737 — condition (a): versioned progress into a non-empty
/// phantom table, gated to non-clone operations (clone continuation is (e)).
fn continuesVersionedProgress(obs: RoundObservation) bool {
if (obs.operation == .clone) return false;
const progressed = obs.versioned_content_delivered or obs.phantom_created;
return progressed and obs.phantom_table_nonempty;
}
/// §3.7 r5/r6, L751-763 — condition (d): the unversioned send/gimme cycle.
/// (d)(1) the send table is non-empty and upload is enabled; (d)(2) a
/// uvgimme was queued this round, and either a uvfile came back or the
/// session hasn't yet exhausted its three-round grace window.
fn continuesUnversionedCycle(obs: RoundObservation) bool {
const d1 = obs.uv_send_table_nonempty and obs.uv_upload_enabled;
const d2 = obs.uvgimme_queued and (obs.uvfile_received or obs.session_round < 3);
return d1 or d2;
}
/// §3.7 r7, L765-769 — condition (e): modern-clone continuation. A positive
/// NEXT alone does not continue the round (L1915-1924: the peer must also
/// have grown the artifact count this round).
fn continuesClone(obs: RoundObservation) bool {
if (obs.operation != .clone) return false;
if (obs.first_clone_round or obs.artifact_received_this_round) return true;
return obs.clone_seqno_next > 0 and obs.artifact_count_grew;
}
/// §3.7 r1-r10, L717-795 — the client issues another round iff at least one
/// of conditions (a)-(f) holds; unresolved phantoms alone (r9) never do.
pub fn continueExchange(obs: RoundObservation) bool {
std.debug.assert(obs.clone_seqno_next >= 0);
std.debug.assert(!obs.first_clone_round or obs.operation == .clone);
return continuesVersionedProgress(obs) or // (a)
obs.content_queued or // (b)
obs.private_discovery_pending or // (c)
continuesUnversionedCycle(obs) or // (d)
continuesClone(obs) or // (e)
obs.config_exchange_pending; // (f)
}
// ---------------------------------------------------------------------------
// Section 3.4 — The Clone Loop (pointer clause: no new bytes, no new
// mechanism — clone rides the operation-agnostic round primitives)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Section 3.8 — Client Protocol State Machine
// ---------------------------------------------------------------------------
/// §3.8 r12/r13, L907-922 — INIT departs to CLONE_ROUND0 for a modern clone;
/// push/pull/sync all send an authenticated first round straight to EXCHANGE.
pub fn initialState(op: Operation) ClientState {
return switch (op) {
.clone => .clone_round0,
.pull, .push, .sync => .exchange,
};
}
/// §3.8 Table 3 — the legal events driving a round0/exchange transition.
pub const Event = enum {
clone_reply_processed, // r14
clone_fatal, // r15
round_continues, // r16
round_terminates, // r17
fatal_error, // r18
};
/// §3.8 Table 3, r14-r18 — the client's state transition function. Illegal
/// (state, event) pairs and the absorbing terminals (`terminated`,
/// `aborted`) are never re-stepped: unreachable, negative space.
pub fn transition(state: ClientState, event: Event) ClientState {
return switch (state) {
.clone_round0 => switch (event) {
.clone_reply_processed => .exchange, // r14
.clone_fatal => .aborted, // r15
.round_continues, .round_terminates, .fatal_error => unreachable,
},
.exchange => switch (event) {
.round_continues => .exchange, // r16
.round_terminates => .terminated, // r17
.fatal_error => .aborted, // r18
.clone_reply_processed, .clone_fatal => unreachable,
},
.init, .terminated, .aborted => unreachable,
};
}
/// §3.8 r19, L965-972 — the closed nine-cause fatal-error set, each cited to
/// its governing subsection.
pub const FatalCause = enum {
// server-emitted error cards -- silently discarded in CLONE_ROUND0 (§11.1).
error_card, // §11.1 L2604-2607
project_code_mismatch, // §9.1 -> error card, §11.1 L2619-2623
auth_failure, // §6 -> error card, §11.1 L2619-2623
peer_version_refusal, // §7.6 L1797-1803 -> error card
// client-detected faults -- fatal in every state, round0 included.
unknown_operator, // §3.9 L983-985 / §5
malformed_content, // §3.9 L983-985 / §7
over_length_line, // §5 L2666
redirect_limit, // §4 L1153 / Table-3 r15
clone_seqno_negative, // §8.2 L1936-1937
};
/// §11.1 L2619-2623, §7.6 L1797-1803 — the four causes above that reach the
/// client only as a server-emitted `error` card, so they inherit
/// `clientOnErrorCard`'s round-0 discard exception.
fn deliveredByErrorCard(cause: FatalCause) bool {
return switch (cause) {
.error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal => true,
.unknown_operator, .malformed_content, .over_length_line, .redirect_limit, .clone_seqno_negative => false,
};
}
/// §3.8 r19 — the nine-cause set is fatal in every state, except that the
/// four error-card-delivered causes are discarded in CLONE_ROUND0, per
/// `clientOnErrorCard`'s single round-0 exception (reused, not restated).
pub fn causeIsFatal(state: ClientState, cause: FatalCause) bool {
if (deliveredByErrorCard(cause)) return clientOnErrorCard(state) == .terminate;
return true;
}
/// §8.2 L1936-1937 — a negative clone_seqno is an unconditional abort, in
/// every state including CLONE_ROUND0 (§3.8 L805-807: the cited section
/// governs where it differs from the state-machine restatement).
pub fn cloneSeqnoIsFatal(next: i64) bool {
return next < 0;
}
// ---------------------------------------------------------------------------
// Section 3.9 — Robustness Posture (coverage over the settled authorities;
// §3.9 is a restatement, so no new mechanism — each clause binds to the
// existing classifier/state-machine that owns it).
// ---------------------------------------------------------------------------
// §3.9 r4 helper — a canonical card is single-space separated, has no
// leading/trailing Fossil whitespace, no separator run wider than one byte,
// and re-tokenizes without surplus (§3.9 L994-995).
// ---------------------------------------------------------------------------
// Section 3.10 — Round Message Layout (non-authoritative quick reference,
// L1016-1018: §3.2 and each card's owning section govern; these tests bind
// the restated rows onto the authorities, adding no §3.10 mechanism).
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Section 3.1 / 3.3 — Round Tables & Phantom Lifecycle
// ---------------------------------------------------------------------------
/// §3.1 L411-417, r26/r27 — the two per-round tracking tables (Table 1: peer-
/// has, peer-lacks). Both are created-or-reset at round start and torn down
/// at round end (r20/r21, L433-436); they own no state past that boundary.
/// The string maps do not own their keys, so `reset`/`deinit` free the
/// `gpa.dupe`'d hashes explicitly, mirroring `MemStore` (repostate.zig).
pub const RoundTables = struct {
gpa: std.mem.Allocator,
peer_has: std.StringHashMapUnmanaged(void) = .empty,
peer_lacks: std.StringHashMapUnmanaged(void) = .empty,
pub fn init(gpa: std.mem.Allocator) RoundTables {
return .{ .gpa = gpa };
}
pub fn deinit(self: *RoundTables) void {
freeKeys(self.gpa, &self.peer_has);
self.peer_has.deinit(self.gpa);
freeKeys(self.gpa, &self.peer_lacks);
self.peer_lacks.deinit(self.gpa);
self.* = undefined;
}
/// r20/r21, L433-436 — create-or-reset at a round boundary: free every
/// owned key and empty both tables, keeping bucket storage (capacity is
/// bounded by one round's hash count).
pub fn reset(self: *RoundTables) void {
freeKeys(self.gpa, &self.peer_has);
self.peer_has.clearRetainingCapacity();
freeKeys(self.gpa, &self.peer_lacks);
self.peer_lacks.clearRetainingCapacity();
std.debug.assert(self.peer_has.count() == 0 and self.peer_lacks.count() == 0);
}
/// r26, L630-633 — record a hash the peer announced or sent this round.
pub fn recordPeerHas(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void {
try recordInto(self.gpa, &self.peer_has, hash);
std.debug.assert(self.peerHas(hash));
}
/// r27, L633-637 — record a hash the peer requested with gimme this round.
pub fn recordPeerLacks(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void {
try recordInto(self.gpa, &self.peer_lacks, hash);
std.debug.assert(self.peerLacks(hash));
}
pub fn peerHas(self: *const RoundTables, hash: []const u8) bool {
return self.peer_has.contains(hash);
}
pub fn peerLacks(self: *const RoundTables, hash: []const u8) bool {
return self.peer_lacks.contains(hash);
}
fn freeKeys(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void)) void {
var it = map.keyIterator();
while (it.next()) |key| gpa.free(key.*);
}
/// Reserves capacity on `map` itself before dupe-ing `hash`, so the
/// trailing insert is `putAssumeCapacity` (infallible) and an OOM can
/// only happen before any owned memory exists — no partial state, no
/// errdefer needed. A repeat record of an already-held key returns
/// before the dupe, so it never leaks a duplicate key.
fn recordInto(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void), hash: []const u8) error{OutOfMemory}!void {
std.debug.assert(hash.len == 40 or hash.len == 64);
if (map.contains(hash)) return;
try map.ensureUnusedCapacity(gpa, 1);
const owned = try gpa.dupe(u8, hash);
map.putAssumeCapacity(owned, {});
}
};
/// §3.3 r24(1), L621-625 — whether `side` can obtain content in operation
/// `op`. Sync is bidirectional, so it appears on both arms.
pub fn igotCanObtain(side: Side, op: Operation) bool {
return switch (side) {
.client => switch (op) {
.pull, .clone, .sync => true,
.push => false,
},
.server => switch (op) {
.push, .sync => true,
.pull, .clone => false,
},
};
}
/// §3.3 L849-853, §3.2 L763 — whether the client may upload content in
/// response to a peer gimme: only when the operation direction permits
/// sending — push, and the push half of sync. pull and clone are
/// receive-only. (The truth table coincides with the server-obtains set of
/// `igotCanObtain`, but this names the distinct send-direction decision.)
pub fn clientSendsContent(op: Operation) bool {
return op == .push or op == .sync;
}
pub const IgotDisposition = enum { create_phantom, ignore };
/// §3.3 r24(1), L621-625 — igot creates a phantom iff the receiver does not
/// already hold the artifact and is in an operation state that can obtain it.
pub fn igotDisposition(side: Side, op: Operation, already_holds: bool) IgotDisposition {
const result: IgotDisposition = if (!already_holds and igotCanObtain(side, op)) .create_phantom else .ignore;
std.debug.assert(result != .create_phantom or (!already_holds and igotCanObtain(side, op)));
return result;
}
/// §3.3 (a)/(b)/(c), L644-652 — the freshly-per-round gimme eligibility
/// filter inputs.
pub const Eligibility = struct {
peer_lacks: bool,
shunned: bool,
private: bool,
private_sync_enabled: bool,
};
/// §3.3, L644-652 — order (a) peer-lacks, (b) shunned, (c) private-without-
/// sync-enabled; a phantom is gimme-eligible only if none reject it.
pub fn gimmeEligible(e: Eligibility) bool {
if (e.peer_lacks) return false;
if (e.shunned) return false;
if (e.private and !e.private_sync_enabled) return false;
const result = true;
std.debug.assert(!e.peer_lacks and !e.shunned);
return result;
}
pub const GimmeResponse = enum { send, silent_skip };
/// §3.3, L654-662 — inbound-gimme disposition. Caller records peer-lacks
/// (r27) before calling. `.send` only when held, direction permits sending,
/// and policy permits disclosure; every other case is a silent skip — no
/// content, no error card, no deferred send (r34/r35 and the policy-denied
/// case fold to the same byte-neutral outcome since none emits a card).
pub fn gimmeResponse(holds: bool, direction_permits: bool, policy_permits: bool) GimmeResponse {
const result: GimmeResponse = if (holds and direction_permits and policy_permits) .send else .silent_skip;
std.debug.assert(result != .send or (holds and direction_permits and policy_permits));
return result;
}
// §3.3 r24: a phantom is a hash known with content absent — this is
// `ArtifactStore`'s own contract (`content(rid) == null`), not a sync-layer
// mechanism. No `isPhantom` wrapper enters this file; the definition is
// asserted directly against the store below (store-contract dependency
// test, not sync coverage).
// §3.3 r36: storing content dephantomizes — again the store's own contract,
// asserted directly (store-contract dependency test, not sync coverage).
/// §3.2 r22/r23, L455-473 — request-card classification for the ordering
/// rule. `content_request` covers clone and gimme (L471); `uvgimme` is
/// unversioned and deferred. `send_private`/`login` classify as `.other`
/// (L458-463), so the two transport/order exceptions fall out of the
/// ordering scan below without special-casing them.
pub const RequestCard = enum { client_version, content_request, other };
pub const RequestOrder = enum { ok, missing_client_version, version_after_content_request };
/// §3.2, L455-473 — a request is well-ordered iff it carries a
/// client-version pragma and no content-requesting card precedes it. An
/// empty body has no client-version card, so it is itself the r22
/// rejection — no length assertion, no panic (an empty request is legal
/// input, not a caller bug).
pub fn checkRequestOrder(cards: []const RequestCard) RequestOrder {
var version_index: ?usize = null;
var content_index: ?usize = null;
for (cards, 0..) |card, i| {
switch (card) {
.client_version => if (version_index == null) {
version_index = i;
},
.content_request => if (content_index == null) {
content_index = i;
},
.other => {},
}
}
const result: RequestOrder = if (version_index == null)
.missing_client_version
else if (content_index != null and content_index.? < version_index.?)
.version_after_content_request
else
.ok;
std.debug.assert(result != .ok or (version_index != null and (content_index == null or content_index.? > version_index.?)));
return result;
}
// ---------------------------------------------------------------------------
// Section 8 — Clone
// ---------------------------------------------------------------------------
/// §8.1 L1867-1876 — the sole interoperable clone transfer path this
/// document defines. Orthogonal to the release integer carried in
/// version pragmas (L1881-1885): the 20000 threshold in `sha3PeerVersionGate`
/// names a Fossil release, never this transfer-path version.
pub const clone_version: u8 = 3;
/// §8.1 L1867 — write `clone VERSION SEQNO`. SEQNO is a blob-table row
/// identifier (§8.2 L1889), always non-negative on the request side (a
/// negative NEXT received in a reply is fatal before it is ever
/// re-sent, per `cloneSeqnoIsFatal`).
pub fn writeCloneCard(seqno: u64, out: []u8) std.fmt.BufPrintError![]u8 {
const needed = std.fmt.count("clone {d} {d}", .{ clone_version, seqno });
std.debug.assert(needed <= 28); // "clone 3 " + max u64 digits, ceiling
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "clone {d} {d}", .{ clone_version, seqno });
}
/// §8.2 L1896-1937 — parse the reply's `clone_seqno NEXT` card. A
/// present-but-non-integer NEXT is the sole real parse failure; a
/// negative value parses cleanly here and is instead the fatal-cause
/// path's job (`cloneSeqnoIsFatal`), not a rejection at this layer.
pub fn parseCloneSeqno(tokens: Tokens) error{Malformed}!i64 {
if (tokens.len < 2) return error.Malformed;
return std.fmt.parseInt(i64, tokens.items[1], 10) catch error.Malformed;
}
/// §8.2 L1965-1983 loop-tail — the shape of the next request, given
/// whether Section 3.7 continues the exchange and the NEXT just
/// received. `next > 0` sends another clone card; a live continuation
/// with NEXT already exhausted is a cleanup round (no clone card, L1930-
/// 1934); no continuation stops. The MUST-NOT-after-0 rule (L1927-1928)
/// falls out structurally: `.clone_card` is unreachable once NEXT is 0.
pub const NextRequest = enum { clone_card, cleanup_round, stop };
pub fn nextRequestKind(continuing: bool, clone_seqno_next: i64) NextRequest {
std.debug.assert(clone_seqno_next >= 0); // negative NEXT aborts before this is reached
if (!continuing) return .stop;
return if (clone_seqno_next > 0) .clone_card else .cleanup_round;
}
/// §8.2 L1917-1921 — the server's matching liveness invariant: a
/// positive NEXT is only legal when the round it ends also advanced the
/// sequence. The store-backed cfile emission path that this predicate
/// guards is deferred (needs an ArtifactStore/blob-table iterator); this
/// is the pure invariant that path will assert against.
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;
}
// ---------------------------------------------------------------------------
// Section 9 — Synchronisation Strategies
// ---------------------------------------------------------------------------
/// §9.1 L2035-2037 — push and pull share one wire shape (`KEYWORD
/// SERVERCODE PROJECTCODE`); one writer keyed on direction avoids
/// duplicating the format string.
pub const PushPull = enum { push, pull };
/// §9.1 L2035-2037 — write `push SERVERCODE PROJECTCODE` or `pull
/// SERVERCODE PROJECTCODE`. Neither field may embed Fossil whitespace —
/// that would silently re-tokenize the card on the wire.
pub fn writePushPullCard(dir: PushPull, servercode: []const u8, projectcode: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
std.debug.assert(!containsFossilWhitespace(servercode) and !containsFossilWhitespace(projectcode));
std.debug.assert(servercode.len <= max_line_len and projectcode.len <= max_line_len);
const keyword = switch (dir) {
.push => "push",
.pull => "pull",
};
const needed = std.fmt.count("{s} {s} {s}", .{ keyword, servercode, projectcode });
std.debug.assert(out.len >= needed);
return try std.fmt.bufPrint(out, "{s} {s} {s}", .{ keyword, servercode, projectcode });
}
/// §9.1/§8.3 L1999-2002 — a parsed `push|pull SERVERCODE PROJECTCODE`
/// card; PROJECTCODE is also how a clone client, unauthenticated on
/// round 0, first learns the peer's project code (§6.3).
pub const PushPullCard = struct { servercode: []const u8, projectcode: []const u8 };
/// §9.1 L2035-2037 — positional parse; the caller already resolved the
/// operator (push vs. pull) via `classifyCard`/`parseOperator`, so one
/// parser serves both keywords, mirroring `parseReqconfigName`.
pub fn parsePushPull(tokens: Tokens) ?PushPullCard {
std.debug.assert(tokens.len >= 1);
const op = parseOperator(tokens.items[0]);
std.debug.assert(op == .push or op == .pull);
if (tokens.len < 3) return null;
return .{ .servercode = tokens.items[1], .projectcode = tokens.items[2] };
}
/// §9.1 L2039-2041 — the server's project-code check: a mismatch is
/// fatal, never a silent proceed.
pub const SyncProjectCode = enum { proceed, refuse_fatal };
pub fn serverSyncProjectCodeDecision(stored: []const u8, received: []const u8) SyncProjectCode {
std.debug.assert(stored.len <= max_line_len and received.len <= max_line_len);
return if (std.mem.eql(u8, stored, received)) .proceed else .refuse_fatal;
}
/// §9.2 L2199-2202, §10.1 L2308-2312 (DATE is YYYYMMDD) — the private-
/// igot suppression threshold as an integer.
pub const private_igot_min_peer_date: u32 = 20200413;
/// §9.2 L2199-2202 — whether a private artifact's igot announcement is
/// suppressed for the recorded peer. `null` (no recorded date) does not
/// satisfy "recorded date is before 2020-04-13" -- the condition needs a
/// date to test, so an unrecorded peer announces normally.
pub const PrivateIgotAction = enum { omit, announce_private };
pub fn privateIgotAction(peer_date: ?u32) PrivateIgotAction {
comptime std.debug.assert(private_igot_min_peer_date == 20200413);
const date = peer_date orelse return .announce_private;
return if (date < private_igot_min_peer_date) .omit else .announce_private;
}
// ---------------------------------------------------------------------------
// Section 4 — Transport
// ---------------------------------------------------------------------------
/// §4.1 L1113-1187 — the three protocol-defined Content-Type values.
pub const MediaType = enum { x_fossil, x_fossil_debug, x_fossil_uncompressed };
/// §4.1 per-media-type facts, one row per Content-Type. Callers read a
/// field directly rather than going through a per-fact accessor.
pub const MediaTypeProps = struct {
/// The literal Content-Type wire string.
wire: []const u8,
/// r15 true; r16/r17 false — is the outer message body zlib-wrapped?
outer_compressed: bool,
/// §4 r2/r3 — may a request arrive with this Content-Type?
valid_request_type: bool,
/// r17 — used only for a clone-v3 reply, never a request.
clone_reply_only: bool,
/// r16 — x-fossil-debug is diagnostic-only, not for production sync.
production_allowed: bool,
};
pub const media_props = std.EnumArray(MediaType, MediaTypeProps).init(.{
.x_fossil = .{ .wire = "application/x-fossil", .outer_compressed = true, .valid_request_type = true, .clone_reply_only = false, .production_allowed = true },
.x_fossil_debug = .{ .wire = "application/x-fossil-debug", .outer_compressed = false, .valid_request_type = true, .clone_reply_only = false, .production_allowed = false },
.x_fossil_uncompressed = .{ .wire = "application/x-fossil-uncompressed", .outer_compressed = false, .valid_request_type = false, .clone_reply_only = true, .production_allowed = true },
});
/// §4 r2/r3 — resolve a Content-Type string against the table; null when
/// it names none of the three protocol media types.
pub fn parseMediaType(s: []const u8) ?MediaType {
for (std.meta.tags(MediaType)) |v| {
if (std.mem.eql(u8, s, media_props.get(v).wire)) return v;
}
return null;
}
/// §4 — the four ways a sync message reaches the wire; only http/https
/// carry redirects (r13).
pub const Transport = enum { http, https, ssh, file };
/// §4 L1110-1113 (r1) — the request-target is the repository URL's path,
/// unchanged; an empty path uses "/". Total and allocation-free: the
/// result is always a view into `repo_path` or the "/" literal, so the
/// client structurally cannot append a path component (never /xfer).
pub fn syncRequestTarget(repo_path: []const u8) []const u8 {
return if (repo_path.len == 0) "/" else repo_path;
}
// §4 L1139-1141 (r6, DEFERRED): the request Content-Length is the exact
// byte count of the payload that follows the header block — a bytes-not-
// cards HTTP-header fact the CLI request builder computes when it emits
// the request; there is no computation to perform or test at this layer.
/// §4 L1128-1133 (r4) — the sole protocol-defined nonstandard header.
pub const ssh_transport_header: []const u8 = "X-Fossil-Transport: SSH";
/// §4 L1128-1133 (r4) — sent only when the transport is SSH.
pub fn needsSshTransportHeader(t: Transport) bool {
return switch (t) {
.ssh => true,
.http, .https, .file => false,
};
}
/// §4 L1135-1139 (r5) outcome: a POST is processed as sync cards; any
/// other method is never processed and is redirected home instead.
pub const MethodDisposition = enum { process, redirect_home };
/// §4 L1135-1139 (r5) — a server MUST NOT process sync cards from a
/// non-POST request; it redirects such a request to the repository home
/// page instead.
pub fn serverMethodDisposition(is_post: bool) MethodDisposition {
return if (is_post) .process else .redirect_home;
}
/// §4 L1143-1149 (r7/r8) — how the reply body is framed: an exact byte
/// count from Content-Length, or read-until-close when the reply carries
/// none.
pub const ReplyFraming = union(enum) { exact: usize, until_close };
/// §4 L1143-1149 (r7/r8).
pub fn replyFraming(content_length: ?usize) ReplyFraming {
return if (content_length) |n| .{ .exact = n } else .until_close;
}
/// §4 L1146-1149 (r8) — the hard cap, in bytes, on a Content-Length-less
/// reply body (512 MiB).
pub const reply_body_cap: usize = 536_870_912;
/// §4 L1148-1149 (r9) — a reply that would exceed `reply_body_cap` MUST
/// be rejected rather than buffered without bound.
pub fn replyCapCheck(len: usize) error{ReplyTooLarge}!void {
if (len > reply_body_cap) return error.ReplyTooLarge;
}
/// §4 L1151-1153 (r10) — at most this many redirects are followed for a
/// single exchange.
pub const max_redirects: usize = 19;
/// §4 L1151-1154 (r10/r11) — `followed` is the count already consumed by
/// this exchange; the twentieth attempt (`followed == max_redirects`)
/// aborts with a fatal redirect-limit error rather than being followed.
pub fn redirectAllowed(followed: usize) error{RedirectLimit}!void {
if (followed >= max_redirects) return error.RedirectLimit;
}
/// §4 L1154-1156 (r12) — a redirect MUST NOT downgrade an HTTPS exchange
/// to plaintext HTTP.
///
/// §12.2 L2721-2734 restates the surrounding transport posture, none of it
/// enforceable by a sans-IO format library that holds no socket:
/// confidentiality, server authentication, and replay protection of
/// credential-bearing traffic are the transport layer's responsibility
/// (§12.2-r1 L2721-2723); a deployment carrying such traffic MUST use HTTPS,
/// SSH, or an equivalent protected transport (§12.2-r2 L2723-2727) and SHOULD
/// NOT use plaintext HTTP in production (§12.2-r3 L2727-2729). Refusing an
/// HTTPS->HTTP redirect is this library's sole in-boundary contribution; the
/// closing §12.2 observation (L2729-2731 — an intermediary reading a private
/// session reads the payloads) is non-normative rationale, no test.
pub fn checkRedirectDowngrade(from: Transport, to: Transport) error{InsecureRedirect}!void {
if (from == .https and to == .http) return error.InsecureRedirect;
}
/// §4 L1156-1157 (r13) — SSH and local-file transports never follow a
/// redirect; there is no HTTP exchange for a redirect to occur within.
pub fn redirectAllowedForTransport(t: Transport) bool {
return switch (t) {
.http, .https => true,
.ssh, .file => false,
};
}
/// §4 L1157-1165 — when following a redirect, strip a trailing "/xfer"
/// path segment from the target Location, repeating while one remains,
/// then use the (possibly shortened) Location verbatim as the new base.
/// No other path transformation; the original repository URL's path is
/// never consulted. Matched as a case-sensitive literal, exactly as
/// spelled in the RFC text. Total and allocation-free: returns a
/// subslice of `location`. Stripping is confined to the path component,
/// so an authority named "xfer" (e.g. https://xfer) is left intact.
pub fn stripRedirectXfer(location: []const u8) []const u8 {
const scheme_sep = "://";
const path_start = if (std.mem.indexOf(u8, location, scheme_sep)) |auth|
std.mem.indexOfScalarPos(u8, location, auth + scheme_sep.len, '/') orelse location.len
else
0;
const xfer = "/xfer";
var end: usize = location.len;
// Bounded: each iteration removes exactly xfer.len (5) bytes and end
// is floored at path_start, so this runs at most
// (location.len - path_start) / 5 times.
while (end >= path_start + xfer.len and std.mem.eql(u8, location[end - xfer.len .. end], xfer)) {
end -= xfer.len;
}
std.debug.assert(end >= path_start and end <= location.len);
// Negative space: the fixpoint — the returned slice does not end in
// a strippable "/xfer" (this is exactly the loop's exit condition).
std.debug.assert(!(end >= path_start + xfer.len and std.mem.eql(u8, location[end - xfer.len .. end], xfer)));
return location[0..end];
}
// ---------------------------------------------------------------------------
// Section 4.1 — Message Compression: the zlib container
// ---------------------------------------------------------------------------
/// §4.1 L1199-1208 — r19 uses the zlib default level; r20 (inline-login
/// prepend) uses level 9. r21: the decompressor never depends on which.
pub const CompressLevel = enum { default, best };
fn compressOptions(level: CompressLevel) flate.Compress.Options {
return switch (level) {
.default => .level_6,
.best => .level_9,
};
}
/// `Compress.init` asserts its output writer already has more than 8
/// bytes of buffer capacity; `Writer.Allocating` starts empty, so the
/// encoder pre-sizes before calling it. 64 is a cited headroom over that
/// `>8` floor, comfortably covering the 4-byte length prefix plus the
/// 2-byte zlib header this container always writes first.
const compress_out_min: usize = 64;
pub const ContainerEncodeError = error{ OutOfMemory, InputTooLarge };
/// §4.1 L1192-1197 (r18) — the compressed container: a 4-byte big-endian
/// uncompressed length, then a standard zlib [RFC1950] stream (2-byte
/// header, DEFLATE body, 4-byte Adler-32 trailer). Not raw DEFLATE, not
/// gzip. Shared with the §7.2 cfile container (deferred here — no
/// cross-layer caller exists yet, so it stays sync-layer-local).
pub fn zlibContainerEncode(gpa: std.mem.Allocator, input: []const u8, level: CompressLevel) ContainerEncodeError![]u8 {
if (input.len > std.math.maxInt(u32)) return error.InputTooLarge;
var aw = std.Io.Writer.Allocating.init(gpa);
defer aw.deinit();
aw.ensureUnusedCapacity(compress_out_min) catch return error.OutOfMemory;
aw.writer.writeInt(u32, @intCast(input.len), .big) catch return error.OutOfMemory;
const window = gpa.alloc(u8, flate.max_window_len) catch return error.OutOfMemory;
defer gpa.free(window);
var c = flate.Compress.init(&aw.writer, window, .zlib, compressOptions(level)) catch return error.OutOfMemory;
c.writer.writeAll(input) catch return error.OutOfMemory;
c.finish() catch return error.OutOfMemory;
const out = try aw.toOwnedSlice();
std.debug.assert(std.mem.readInt(u32, out[0..4], .big) == input.len);
return out;
}
pub const ContainerDecodeError = error{ OutOfMemory, TruncatedHeader, InvalidStream, OutputTooLarge };
/// §4.1 L1197-1213 — decode a `zlibContainerEncode` container. The
/// length field is a capacity, not an exact length: a decode that
/// finishes within that capacity is accepted and returns the actual
/// decoded byte count, which may be less than declared. `max_output`
/// bounds the declared length (rejecting a lying header before any
/// decompression work happens) and the actual stream is pumped no
/// further than `declared + 1` bytes, so a truncated-length lie cannot
/// drive unbounded allocation; the `+1` is what detects a stream that
/// overruns its declared capacity. Callers pass `reply_body_cap` as
/// `max_output` to tie this to r8/r9.
pub fn zlibContainerDecode(gpa: std.mem.Allocator, bytes: []const u8, max_output: usize) ContainerDecodeError![]u8 {
if (bytes.len < 4) return error.TruncatedHeader;
const declared = std.mem.readInt(u32, bytes[0..4], .big);
if (declared > max_output) return error.OutputTooLarge;
var fr = std.Io.Reader.fixed(bytes[4..]);
const window = gpa.alloc(u8, flate.max_window_len) catch return error.OutOfMemory;
defer gpa.free(window);
var dc = flate.Decompress.init(&fr, .zlib, window);
var aw = std.Io.Writer.Allocating.init(gpa);
defer aw.deinit();
const cap: usize = @as(usize, declared) + 1;
var total: usize = 0;
// `stream()` may legitimately return 0 progress on a call that only
// does internal bookkeeping (e.g. parsing a block header) without
// yet emitting output bytes, so 0 is not itself a completion signal
// — only `error.EndOfStream` or reaching `cap` ends the pump. The
// iteration count is still explicitly bounded (input size plus the
// output cap plus headroom) so a stdlib regression that stalled
// forever would surface as `InvalidStream` rather than hang.
const max_iters: usize = bytes.len + cap + 64;
var iters: usize = 0;
while (total < cap and iters < max_iters) : (iters += 1) {
const n = dc.reader.stream(&aw.writer, .limited(cap - total)) catch |err| switch (err) {
error.EndOfStream => break,
error.WriteFailed => return error.OutOfMemory,
error.ReadFailed => return error.InvalidStream,
};
total += n;
}
if (iters >= max_iters) return error.InvalidStream;
if (total > declared) return error.OutputTooLarge; // §4.1 L1209-1210: must fit within capacity
const out = try aw.toOwnedSlice();
std.debug.assert(out.len == total and out.len <= declared); // postcondition: accounting + capacity ceiling
return out;
}
// ---------------------------------------------------------------------------
// Section 4.2 — Round Structure
// ---------------------------------------------------------------------------
/// §4.2 L1212-1216 (r22/r23) — the outcome of probing one more offset
/// into a reply body: another card follows, the body is exhausted (a
/// clean round end, not an error), or a partial trailing line remains.
pub const ReplyBodyStep = enum { card_ready, round_complete, truncated };
/// §4.2 L1212-1216 — a round is one POST/response pair ending at body
/// exhaustion (r22); exhaustion at a card boundary is not an error
/// (r23), since there is no terminal card in the protocol.
pub fn replyBodyStep(body: []const u8, offset: usize) ReplyBodyStep {
std.debug.assert(offset <= body.len);
if (offset == body.len) return .round_complete;
_ = readLine(body, offset) catch return .truncated;
return .card_ready;
}
// ---------------------------------------------------------------------------
// Section 6 — Authentication
// ---------------------------------------------------------------------------
/// §6 — 40 lowercase-hex ASCII bytes; every digest this section produces.
pub const HashHex = [40]u8;
/// §6 — the one digest->hex conversion site every §6 formula funnels
/// through, so a hex-case or width bug can only ever be fixed in one place.
fn sha1FinalHex(digest: [20]u8) HashHex {
return std.fmt.bytesToHex(digest, .lower);
}
/// §6 — SHA-1 [RFC3174] of `parts` concatenated with no separator, as 40
/// lowercase hex. The sole hashing funnel §6.2/§6.3/§6.4 share; a part
/// boundary never enters the digest, so callers own their own separators.
fn sha1HexOf(parts: []const []const u8) HashHex {
var hasher = std.crypto.hash.Sha1.init(.{});
for (parts) |p| hasher.update(p);
var digest: [20]u8 = undefined;
hasher.final(&digest);
return sha1FinalHex(digest);
}
/// §6.2 — NONCE = SHA1(body bytes after the login-card LF). `body_tail`
/// inherits the §4 r8 reply-body bound since it is itself a message body.
pub fn computeNonce(body_tail: []const u8) HashHex {
std.debug.assert(body_tail.len <= reply_body_cap);
return sha1HexOf(&.{body_tail});
}
/// §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);
}
/// §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.5 — RFC 3986 unreserved bytes (letters, digits, '-', '.', '_', '~')
/// pass through unescaped; every other byte is percent-encoded. §6.5 says
/// only "URL-unsafe bytes ... are percent-encoded" without naming an exact
/// table, so this is the conventional unreserved set — not literally the
/// historical application/x-www-form-urlencoded charset, which omits '~'
/// and adds '*'.
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;
}
/// §6.5 — decode URL form-encoded bytes into `out` ('+' -> SP, `%XX` ->
/// byte); null on a truncated or non-hex escape. Decoding reverses
/// whatever safe-byte choice the encoder made, so it needs no charset
/// table of its own.
/// Decoding only ever shrinks, so `out.len >= value.len` always suffices.
fn urlFormDecode(value: []const u8, out: []u8) ?[]u8 {
std.debug.assert(out.len >= value.len);
var i: usize = 0;
var o: usize = 0;
while (i < value.len) {
const b = value[i];
if (b == '+') {
out[o] = ' ';
o += 1;
i += 1;
} else if (b == '%') {
if (i + 2 >= value.len) return null;
const hi = hexNibble(value[i + 1]) orelse return null;
const lo = hexNibble(value[i + 2]) orelse return null;
out[o] = (@as(u8, hi) << 4) | @as(u8, lo);
o += 1;
i += 3;
} else {
out[o] = b;
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 — decode an `x-f-l-c` cookie value into `out`, then parse it by
/// the same §6.1 path as a body login card; null on any malformed value.
/// `out` is a caller buffer, `out.len >= value.len` required (decode never
/// grows) and a decoded line over `max_line_len - 1` or carrying an
/// embedded LF is rejected explicitly — the value is untrusted wire input,
/// so `tokenizeCard`'s own preconditions must be established here first.
pub fn parseCookieLogin(value: []const u8, out: []u8) ?LoginCard {
std.debug.assert(out.len >= value.len);
const decoded = urlFormDecode(value, out) orelse return null;
if (decoded.len == 0 or decoded.len > max_line_len - 1) return null;
if (std.mem.indexOfScalar(u8, decoded, '\n') != null) return null;
return parseLoginCard(tokenizeCard(decoded));
}
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.5 — the login channel a client uses for a round that DOES emit a
/// login (anonymous/nobody/SSH non-emission is §6.1, out of scope; clone
/// round-0 suppression is §8.3's `sendableAfterCloneRound0`, applied by
/// the caller before consulting this).
pub const LoginTransport = enum {
body, // §6.1/§6.5 L1617-1621 — body-embedded login card, no x-f-l-c.
cookie, // §6.5 L1616-1617 — x-f-l-c cookie header, no body login.
/// §6.5 L1616-1617,L1629-1630 — the x-f-l-c header is emitted only
/// in cookie mode; never against an ungated peer.
pub fn emitsCookieHeader(t: LoginTransport) bool {
return t == .cookie;
}
/// §6.1/§6.5 L1608-1609,L1617-1621 — a body login card is present
/// only in body mode; cookie mode suppresses it.
pub fn emitsBodyLogin(t: LoginTransport) bool {
return t == .body;
}
};
/// §6.5 cond 1 L1593-1594 — 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 cond 2 L1596 — timestamp on/after 2025-07-27 11:05:00, recorded
/// as DATE (YYYYMMDD) / TIME (HHMMSS) per §10.1 L2383-2384.
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 L1593-1596,L1618-1619 — both gate conditions hold for the
/// recorded peer. A peer whose server-version has never been observed
/// carries `datetime == null` (§10.1 r14 seeds no timestamp,
/// L2399-2401), so the "not yet observed" fallback (L1618-1619) 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
/// L2383-2384).
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.5 L1614-1621 — cookie transport when the peer is gated, the
/// ordinary §6.1 body transport otherwise. Caller applies the §8.3
/// clone-round-0 login suppression (`sendableAfterCloneRound0`) before
/// calling this.
pub fn loginTransport(peer: Peer) LoginTransport {
const result: LoginTransport = if (peerGatedForCookie(peer)) .cookie else .body;
std.debug.assert((result == .cookie) == peerGatedForCookie(peer));
return result;
}
/// §6.1 L1436-1440, §6.5 L1633-1637 — the one-login-per-request
/// invariant across both channels: an x-f-l-c cookie login
/// (`cookie_login`) plus `body_logins` body login cards totalling more
/// than one is the §11.1 `duplicate_login` protocol error, regardless of
/// gating. `body_logins` is a bounded card-scan count the caller
/// supplies; this function loops, allocates, and recurses none, so it
/// needs no further bound of its own.
pub fn duplicateLogin(cookie_login: bool, body_logins: usize) bool {
const total = @as(usize, @intFromBool(cookie_login)) + body_logins;
std.debug.assert(total >= body_logins); // no wraparound
const result = total > 1;
if (cookie_login and body_logins >= 1) std.debug.assert(result);
if (!cookie_login and body_logins <= 1) std.debug.assert(!result);
return result;
}
// ---------------------------------------------------------------------------
// Section 6.6 — Capability Model
// ---------------------------------------------------------------------------
/// §6.6 L2157-2159 — the capability letters the server assigns: Read (o)
/// permits pull, Write (i) permits push, Clone (g) permits clone, Private
/// (x) permits private-content transfer. Administrator is named in §6.6
/// with no letter (L2159-2160); its configuration / user-management gate is
/// owned by §10.3 serverConfigPushDecision(has_admin), so it is deliberately
/// not a member — no §6.6 byte yields it. Distinct from Operation (round
/// mode) and Operator (card token); clone/private coexist across the three.
pub const Capability = enum { read, write, clone, private };
comptime {
// §6.6 assigns exactly four capability letters (o/i/g/x); admin has none.
std.debug.assert(@typeInfo(Capability).@"enum".fields.len == 4);
}
/// §6.6 L2155 — "Users are assigned capability letters by the server."
/// Maps an assigned letter to its capability; null for any other byte.
pub fn capabilityFromLetter(letter: u8) ?Capability {
return switch (letter) {
'o' => .read,
'i' => .write,
'g' => .clone,
'x' => .private,
else => null,
};
}
/// §6.6 L2160-2162 — the established capability set the server checks each
/// card against (built §6.4 L2021-2024: baseline set, unioned with a
/// successful login card). Bounded by the four-member enum, membership-tested
/// by the gate.
pub const CapabilitySet = std.EnumSet(Capability);
/// §6.6 L2155 — build the established set from the user's assigned letters;
/// a byte that names no §6.6 capability is skipped. Bounded by letters.len,
/// no allocation — the realization of "assigned capability letters".
pub fn capabilitySetFromLetters(letters: []const u8) CapabilitySet {
var set: CapabilitySet = .empty;
for (letters) |c| {
if (capabilityFromLetter(c)) |cap| set.insert(cap);
}
return set;
}
/// §6.4 L2021-2024 — a successful login card is purely additive: the
/// established set is the baseline unioned with the login grant, and a
/// grant can only add bits, never remove one. The baseline itself is
/// host/session-derived (§6.4 L2008-2017) and enters as an injected
/// CapabilitySet — no IO here. Non-failing: no way to remove a bit, so
/// no error union.
pub fn establishedCapabilities(baseline: CapabilitySet, login_grant: CapabilitySet) CapabilitySet {
const result = baseline.unionWith(login_grant);
// §6.4 L2021-2024 additive-only invariant (negative space: no baseline bit is cleared).
std.debug.assert(result.supersetOf(baseline));
return result;
}
/// §6.6 L2157-2159 — the capability a transfer operation requires: pull
/// needs Read (o), push needs Write (i), clone needs Clone (g). `sync` is a
/// combined pull+push round, not a single card, so §6.6 assigns it no
/// single letter and this returns null; its pull and push cards are each
/// gated as processed. Private-content transfer (x) and configuration
/// (admin) are gated by their own §7.5/§10 deciders, never keyed here (that
/// would duplicate those gates). Exhaustive over Operation, no `else`.
pub fn requiredCapability(op: Operation) ?Capability {
return switch (op) {
.pull => .read,
.push => .write,
.clone => .clone,
.sync => null,
};
}
/// §6.6 L2160-2162 — the per-card capability gate's disposition: process
/// the operation, or reject (r8: the server emits an error card). §6.6
/// fixes no error-card text for a lacked transfer capability, so `.reject`
/// IS the "error-card form" here — no fabricated golden bytes.
pub const CapabilityDecision = enum { process, reject };
/// §6.6 L2160-2162 — process the card when the user's established
/// capability set holds the capability the operation requires, else
/// reject. Precondition op != .sync: the combined round is decomposed
/// into per-card pull/push checks upstream and never handed here.
pub fn serverCapabilityDecision(held: CapabilitySet, op: Operation) CapabilityDecision {
std.debug.assert(op != .sync);
const required = requiredCapability(op).?;
const result: CapabilityDecision = if (held.contains(required)) .process else .reject;
std.debug.assert((result == .process) == held.contains(required));
return result;
}
// ---------------------------------------------------------------------------
// Section 7.1 / 7.2 / 7.4 — Content Transfer: file / cfile Cards
// ---------------------------------------------------------------------------
/// §7.1-r1/r5 — a `file` card: HASH, an optional delta basis (four-argument
/// form only), and SIZE, the uncompressed payload byte count.
pub const FileCard = struct {
hash: []const u8,
deltasrc: ?[]const u8,
size: u32,
};
/// §7.2-r1/r2 — a `cfile` card: HASH, an optional delta basis, USIZE (the
/// sender's reconstructed uncompressed size — syntax only, §7.2-r5), and
/// CSIZE, the wire payload byte count that actually frames the payload.
pub const CfileCard = struct {
hash: []const u8,
deltasrc: ?[]const u8,
uncompressed_size: u32,
compressed_size: u32,
};
/// §7 — the sole `file`/`cfile` parse failure. Unlike §10.3's `config`,
/// SIZE/USIZE/CSIZE have no signed literal to reject as a distinct case;
/// a negative token is simply unparseable as `u32` and folds into this.
pub const CardParseError = error{Malformed};
/// §7.1-r1/r5 — parse `file HASH SIZE` or `file HASH DELTASRC SIZE`.
/// Arity alone disambiguates the two forms; DELTASRC is never inspected
/// beyond being present, so this layer takes no hash-policy dependency.
pub fn parseFileCard(tokens: Tokens) CardParseError!FileCard {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .file);
std.debug.assert(tokens.len <= max_tokens);
const result: FileCard = switch (tokens.len) {
3 => .{
.hash = tokens.items[1],
.deltasrc = null,
.size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed,
},
4 => .{
.hash = tokens.items[1],
.deltasrc = tokens.items[2],
.size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
},
else => return CardParseError.Malformed,
};
std.debug.assert((result.deltasrc == null) == (tokens.len == 3));
return result;
}
/// §7.2-r1/r2 — parse `cfile HASH USIZE CSIZE` or
/// `cfile HASH DELTASRC USIZE CSIZE`. Same arity-disambiguation shape as
/// `parseFileCard`.
pub fn parseCfileCard(tokens: Tokens) CardParseError!CfileCard {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .cfile);
std.debug.assert(tokens.len <= max_tokens);
const result: CfileCard = switch (tokens.len) {
4 => .{
.hash = tokens.items[1],
.deltasrc = null,
.uncompressed_size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed,
.compressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
},
5 => .{
.hash = tokens.items[1],
.deltasrc = tokens.items[2],
.uncompressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
.compressed_size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed,
},
else => return CardParseError.Malformed,
};
std.debug.assert((result.deltasrc == null) == (tokens.len == 4));
return result;
}
/// §7.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;
}
/// §7.4-r1 — one delta held pending its basis: HASH's delta bytes against a
/// DELTASRC this receiver does not yet hold. `delta_bytes` is exactly what
/// `consumeContentPayload` framed off the card; nothing further is decoded
/// until the basis arrives.
pub const PendingDelta = struct {
hash: []const u8,
deltasrc: []const u8,
delta_bytes: []const u8,
};
/// §7.4-r1/r3 — deltas retained against an absent basis, replayed once the
/// basis is stored. Fixed capacity rather than a growable list: a sync round
/// transfers a bounded card set (§4-r8's body cap already bounds the number
/// of cards a single body can carry), so a hard cap here catches a runaway
/// peer instead of hiding it behind unbounded allocation.
pub const PendingDeltaQueue = struct {
pub const max_pending: usize = 256;
pub const RetainError = error{QueueFull};
items: [max_pending]PendingDelta = undefined,
len: usize = 0,
/// §7.4-r1 — retain `delta_bytes` against `deltasrc`, deferring
/// reconstruction to `completeFor`. 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, hash: []const u8, deltasrc: []const u8, delta_bytes: []const u8) RetainError!void {
std.debug.assert(self.len <= max_pending);
if (self.len == max_pending) return RetainError.QueueFull;
self.items[self.len] = .{ .hash = hash, .deltasrc = deltasrc, .delta_bytes = delta_bytes };
self.len += 1;
std.debug.assert(self.len <= max_pending);
}
/// §7.4-r3 — `deltasrc` has just been stored (the caller already called
/// `store.put(deltasrc, ...)`); complete every deferred reconstruction
/// that named it as basis via `delta.apply`, land the result with
/// `store.put`, and drop the entry. Swap-remove: the queue carries no
/// arrival order to preserve. Bounded by `self.len <= max_pending`, so
/// the scan below is bounded the same as `retain`'s capacity.
pub fn completeFor(
self: *PendingDeltaQueue,
allocator: std.mem.Allocator,
store: repostate.ArtifactStore,
deltasrc: []const u8,
) (delta.Error || std.mem.Allocator.Error)!void {
// §7.4-r3 cascades: reconstructing a target STORES it, which is itself a
// "source artifact subsequently stored" event for any pending delta that
// named that target as its basis (a delta chain C<-A<-B). Drive the chain
// to fixpoint via a worklist of newly-stored sources. Bounded: each queue
// entry completes at most once (swap-removed), so at most `max_pending`
// target hashes are ever enqueued, plus the initial deltasrc.
var work: [max_pending + 1][]const u8 = undefined;
work[0] = deltasrc;
var work_len: usize = 1;
var done: usize = 0;
while (done < work_len) {
const src = work[done];
done += 1;
var i: usize = 0;
while (i < self.len) {
std.debug.assert(i < max_pending);
if (!std.mem.eql(u8, self.items[i].deltasrc, src)) {
i += 1;
continue;
}
const source_rid = try store.intern(src);
// Precondition: `src` is stored before it enters the worklist —
// the caller stored the initial deltasrc, and every cascaded src
// was just put() below — so it is never a phantom here.
const source_bytes = store.content(source_rid) orelse unreachable;
const reconstructed = try delta.apply(allocator, source_bytes, self.items[i].delta_bytes);
defer allocator.free(reconstructed);
const target_hash = self.items[i].hash;
_ = try store.put(target_hash, reconstructed);
// The target is now stored: enqueue it as a basis for any other
// pending delta (§7.4-r3 cascade).
std.debug.assert(work_len <= max_pending);
work[work_len] = target_hash;
work_len += 1;
self.len -= 1;
self.items[i] = self.items[self.len];
// Do not advance i: index i now holds the swapped-in element.
}
}
}
/// §3.3 L664-680 dangling-delta path — OPTION B content-received
/// derivation. A HASH whose delta payload has been retained (its basis
/// not yet local) is content-received: §3.3 deletes its phantom on delta
/// receipt, so gimme MUST NOT re-request it. The store stays binary
/// (present / phantom), so that state is derived here — a live queue
/// entry naming HASH as target — never a store mutation. The
/// disjunction's other half (content already stored) is the store's own
/// `content(rid) != null`, which the gimme driver checks as it
/// enumerates phantoms; this predicate answers only the retained-delta
/// half. A pure membership test: no error set, errors defined away.
pub fn hasTarget(self: *const PendingDeltaQueue, hash: []const u8) bool {
std.debug.assert(self.len <= max_pending);
std.debug.assert(hash.len == 40 or hash.len == 64);
for (self.items[0..self.len]) |entry|
if (std.mem.eql(u8, entry.hash, hash)) return true;
return false;
}
};
// §A.1 L2998-3035 (Round 2 request of the two-round push/pull worked
// example) — the vector holds the first file card's 272-byte manifest
// payload, the second file card's 26-byte payload, and enough of the
// following gimme card to prove the "no separator" boundary lands
// exactly where the RFC's own byte counts say it does.
// §7.4-r2 — the receiver "MUST create a phantom record for the named
// DELTASRC". A phantom is `ArtifactStore`'s own contract (content(rid) ==
// null), so this is asserted directly against the store, mirroring the
// §3.3 r24 store-contract test above: no sync-layer isPhantom wrapper.
// ---------------------------------------------------------------------------
// Section 7.5 — Private Content
// ---------------------------------------------------------------------------
/// §7.5 L1743-1746 (r1/r2) — the card that must immediately follow a
/// `private` card: content (a file/cfile card carrying the private
/// payload), or a protocol error. `follower` null covers "nothing
/// follows" (also a protocol error). A private card before a uvfile is
/// undefined by the RFC (L1754-1756); ambiguities.md §7.5 r6 (RESOLVED,
/// reading B) routes it here through the same content-bearing-minus-
/// uvfile predicate as every other non-file/cfile follower.
pub const PrivatePairing = enum { content, protocol_error };
pub fn privatePairing(follower: ?Operator) PrivatePairing {
const op = follower orelse return .protocol_error;
const result: PrivatePairing = if (isContentBearing(op) and op != .uvfile) .content else .protocol_error;
std.debug.assert((result == .content) == (follower != null and isContentBearing(follower.?) and follower.? != .uvfile));
return result;
}
/// §7.5 L1750-1752 (r4/r5) — the sole holds-Private gate on receipt: an
/// artifact is stored only when the authenticated user holds the
/// Private capability; otherwise it is discarded without error (r5) —
/// the plain enum, not an error union, is what defines "without error"
/// out of existence.
pub const PrivateReceipt = enum { store, discard };
pub fn privateReceipt(user_has_private: bool) PrivateReceipt {
const result: PrivateReceipt = if (user_has_private) .store else .discard;
std.debug.assert((result == .store) == user_has_private);
return result;
}
/// §7.5 L1758-1764 (r7) — send-side delta-basis gate. Precondition: the
/// artifact being sent is itself public — the boundary only bites when a
/// *public* artifact's on-disk delta basis is private. Sending such a
/// delta to a peer lacking private-content authorization would leak the
/// basis artifact's existence/identity, so the sender is forced to full,
/// non-delta content instead.
pub const DeltaBasisSend = enum { delta_ok, force_full };
pub fn deltaBasisSend(basis_private: bool, peer_has_private: bool) DeltaBasisSend {
const result: DeltaBasisSend = if (basis_private and !peer_has_private) .force_full else .delta_ok;
std.debug.assert((result == .force_full) == (basis_private and !peer_has_private));
return result;
}
/// §7.5 L1769-1774 (r9/r10/r11/r12) — receive-side disposition of a
/// private `igot HASH 1` from a peer that lacks private-content
/// authorization: never an error (r9), never creates a phantom (r10),
/// marks an already-held phantom private (r11), and always records that
/// the peer holds HASH (r12). `already_phantom` is the caller's lookup
/// of its own phantom table; the caller applies `mark_phantom_private`
/// back to that table — same returned-decision contract as
/// `igotDisposition`. No repostate code lands here.
pub const PrivateIgotReceipt = struct {
is_error: bool,
create_phantom: bool,
mark_phantom_private: bool,
record_peer_has: bool,
};
pub fn receivePrivateIgot(already_phantom: bool) PrivateIgotReceipt {
const result: PrivateIgotReceipt = .{
.is_error = false,
.create_phantom = false,
.mark_phantom_private = already_phantom,
.record_peer_has = true,
};
std.debug.assert(!result.is_error and !result.create_phantom and
result.record_peer_has and result.mark_phantom_private == already_phantom);
return result;
}
/// §9.2 L2191-2192 — a parsed `igot HASH` (public) or `igot HASH 1`
/// (private) card. `1` is the only flag value this grammar defines.
pub const IgotCard = struct { hash: []const u8, private: bool };
/// §9.2 L2191-2192 — parse `igot HASH` or `igot HASH 1`. Arity alone
/// disambiguates the two forms, same shape as `parseFileCard`/
/// `parseUvigotCard`; a third token other than the literal "1" is
/// malformed, and any arity outside {2,3} is malformed.
pub fn parseIgotCard(tokens: Tokens) CardParseError!IgotCard {
std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
std.debug.assert(parseOperator(tokens.items[0]) == .igot);
const result: IgotCard = switch (tokens.len) {
2 => .{ .hash = tokens.items[1], .private = false },
3 => blk: {
if (!std.mem.eql(u8, tokens.items[2], "1")) return CardParseError.Malformed;
break :blk .{ .hash = tokens.items[1], .private = true };
},
else => return CardParseError.Malformed,
};
std.debug.assert(!result.private or tokens.len == 3);
return result;
}
/// §3.3 r27 — a parsed `gimme HASH` card.
pub const GimmeCard = struct { hash: []const u8 };
/// §3.3 r27 L633-637 — parse `gimme HASH`. Arity 2 exactly; HASH is a 40-hex
/// SHA1 or 64-hex SHA3 token (the §3.1 hash-token lengths `recordPeerLacks`
/// records into). Any other arity or hash length is malformed and rejected
/// here — so the 40/64 length is never asserted downstream on wire input.
pub fn parseGimmeCard(tokens: Tokens) CardParseError!GimmeCard {
std.debug.assert(tokens.len >= 1);
std.debug.assert(parseOperator(tokens.items[0]) == .gimme);
if (tokens.len != 2) return CardParseError.Malformed;
const hash = tokens.items[1];
if (hash.len != 40 and hash.len != 64) return CardParseError.Malformed;
std.debug.assert(hash.len == 40 or hash.len == 64);
return .{ .hash = hash };
}
// ---------------------------------------------------------------------------
// Section 7.7 — Hash-Policy Admission
// ---------------------------------------------------------------------------
/// §7.7 L1818-1824 — hash-policy admission: a gate on the inbound artifact's
/// HASH NAME (length alone → algorithm, artifact.nameAlgo), governed by the
/// receiver's configured policy. Distinct from content-integrity (r1): it
/// never recomputes or inspects content. Under shun-sha1 a 40-char (SHA1)
/// name is rejected before storage (r2); every other policy admits. One
/// function serves all three call sites — file, cfile, and cfile on the
/// clone 3 SEQNO path (r3/r3b) — since the decision depends only on
/// (policy, name), never on the card type or path.
pub const Admission = enum { admit, reject };
pub fn admitInbound(policy: artifact.HashPolicy, hash: []const u8) Admission {
std.debug.assert(artifact.nameAlgo(hash.len) != null); // parse layer validated the name
const result: Admission = if (policy == .shun_sha1 and hash.len == 40) .reject else .admit;
std.debug.assert((result == .reject) == (policy == .shun_sha1 and hash.len == 40));
return result;
}
/// §7.7 L1826-1832 — the content-integrity check-point schedule for one
/// inbound content card. `pre_store` is the transfer-layer recompute-over-
/// reconstructed-bytes check (artifact.verify on the delta-applied content,
/// not the wire payload — r5); it fires only on non-clone file cards (r6).
/// cfile cards never pre-store-verify in the transfer layer (r7). `at_commit`
/// is the universal: commit-time verification applies on ALL paths (r6/r7
/// second clauses) — always true, pinned by assertion and by every test combo.
pub const IntegritySchedule = struct { pre_store: bool, at_commit: bool };
pub fn integritySchedule(op: Operator, clone_path: bool) IntegritySchedule {
std.debug.assert(op == .file or op == .cfile); // §7.7 governs file/cfile; uvfile is §7.3-r13
const result: IntegritySchedule = .{ .pre_store = (op == .file and !clone_path), .at_commit = true };
std.debug.assert(result.at_commit); // commit-time on all paths, no branch may drop it
std.debug.assert(result.pre_store == (op == .file and !clone_path));
return result;
}
// ---------------------------------------------------------------------------
// Section 7.3 — uvfile Card
// ---------------------------------------------------------------------------
/// §7.3-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 — decimal bit field
/// §7.3 L1758-1759 — the receiver decides deletion from the HASH
/// token, never the 0x0001 bit: a deletion is signaled by HASH
/// equal to "-" (with SIZE 0); a sender emitting a deletion also
/// sets 0x0001, but that bit is sender-side metadata only.
pub fn isDeletion(self: UvfileCard) bool {
return std.mem.eql(u8, self.hash, "-");
}
pub fn contentOmitted(self: UvfileCard) bool {
return self.flags & uvfile_flag_omitted != 0;
}
};
/// §7.3-r15 — the payload-free announcement form of a uvfile's state.
pub const UvigotCard = struct { name: []const u8, mtime: i64, hash: []const u8, size: u32 };
/// §7.3-r17 — a request for one named unversioned file.
pub const UvgimmeCard = struct { name: []const u8 };
/// §7.3-r2 — parse `uvfile NAME MTIME HASH SIZE FLAGS`. Arity 6 equals
/// `max_tokens`, so a 7th token silently drops from `tokens.items`
/// unless `surplus` is checked first (same shape as
/// `parseTimestampComment`'s `len != 5 or surplus` guard) — an
/// arity-only check here would wrongly accept an over-long line.
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.surplus) return CardParseError.Malformed; // §5.2 L1324, §7.3-r2
if (tokens.len != 6) return CardParseError.Malformed;
const mtime = std.fmt.parseInt(i64, tokens.items[2], 10) catch return CardParseError.Malformed;
const size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed;
const flags = std.fmt.parseInt(u32, tokens.items[5], 10) catch return CardParseError.Malformed;
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 = std.fmt.parseInt(i64, tokens.items[2], 10) catch return CardParseError.Malformed;
const size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed;
return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size };
}
/// §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 nor the omitted-content flag is set.
pub fn uvfilePayloadFollows(card: UvfileCard) bool {
const result = card.size > 0 and !card.isDeletion() and !card.contentOmitted();
std.debug.assert(!result or (card.size > 0 and !card.isDeletion() and !card.contentOmitted()));
return result;
}
/// §7.3-r10/r11/r12 — frame the payload declared by `card`, or the
/// empty span immediately after `start` when none follows (r12). The
/// payload-present path delegates to `consumeContentPayload`'s
/// overflow-checked bound.
pub fn consumeUvfilePayload(body: []const u8, start: usize, card: UvfileCard) ContentFrameError!ContentPayload {
std.debug.assert(start <= body.len);
if (!uvfilePayloadFollows(card)) {
const result: ContentPayload = .{ .bytes = body[start..start], .next = start };
std.debug.assert(result.bytes.len == 0 and result.next == start);
return result;
}
return consumeContentPayload(body, start, card.size);
}
/// §7.3-r13 — verify HASH against `content` before the caller stores
/// it. A garbage-length `hash` (neither 40 nor 64 hex) simply fails to
/// verify rather than panicking on an unrecognized algorithm.
pub fn verifyUvContent(hash: []const u8, content: []const u8) bool {
const algo = artifact.nameAlgo(hash.len) orelse return false;
var buf: [64]u8 = undefined;
return std.mem.eql(u8, artifact.artifactName(algo, content, &buf), hash);
}
/// §7.3-r18 — the outcome of landing one incoming uvfile: accepted, a
/// write-permission rejection, or a no-op (identical, local wins, or
/// dry-run).
pub const UvLanding = enum { land, not_authorized, no_op };
/// §7.3-r18, §9.1.1-uv2 — recheck the same status function (`uvDecide`)
/// and the write-permission gate before landing an incoming uvfile.
/// `pull_only` is always passed `false`: it drives only `send_table`
/// (never read on the landing path), and `false` is its
/// least-restrictive setting, so it never masks the `.local` outcome
/// this function reads.
pub fn uvLandDecision(local: ?UvRow, card: UvfileCard, mode: UvMode, may_write: bool) UvLanding {
const result: UvLanding = if (!may_write)
.not_authorized // §7.3-r18
else switch (uvDecide(local, card.mtime, card.hash, mode, false).local) {
.fetch, .record_deletion, .touch_mtime => .land, // remote wins, or a permitted metadata-only update (r14)
.none => .no_op, // identical, local wins, or dry-run: never overwrite
};
std.debug.assert((result == .not_authorized) == !may_write);
return result;
}
/// §7.3-r19 — an accepted uvfile change clears the cached unversioned
/// catalog hash; a rejection or no-op leaves it untouched.
pub fn uvLandingClearsCatalog(outcome: UvLanding) bool {
return outcome == .land;
}
// ---------------------------------------------------------------------------
// Section 9.1.1 — Unversioned-File Synchronisation
// ---------------------------------------------------------------------------
/// §9.1.1 L2064-2067 — a local unversioned-file row as classification sees
/// it. A null hash marks a locally recorded deletion. `size`/`encoding`/
/// `content` belong to the storage layer and play no part in status or
/// catalog-hash math, so they are not carried here.
pub const UvRow = struct {
name: []const u8,
mtime: i64,
hash: ?[]const u8,
};
/// §9.1.1 L2095-2113 — the five-way (0..5) unversioned status code.
pub const UvStatus = enum(u3) {
absent = 0,
remote_wins = 1,
older = 2,
identical = 3,
newer = 4,
local_wins = 5,
};
/// §9.1.1 L2133-2154 — the local-store effect of an (already revert-mapped)
/// status. `fetch` queues uvgimme and drops the row that would be
/// replaced; `record_deletion` stores the received mtime and clears
/// hash/size/encoding/content; `touch_mtime` updates local mtime only.
pub const UvLocalAction = enum { none, fetch, record_deletion, touch_mtime };
/// §9.1.1 L2156-2160 — the send-table effect of an (already revert-mapped)
/// status.
pub const SendTableOp = enum { remove, mtime_only, keep_full };
/// §9.1.1 L2115-2119 — revert/dry-run posture for one uvigot classification.
pub const UvMode = struct {
revert: bool = false,
dry_run: bool = false,
};
/// §9.1.1 — the combined outcome of classifying one uvigot row: the
/// post-revert-map status plus both of its effects, computed together so
/// the revert map is applied exactly once (never for one effect but not
/// the other).
pub const UvDecision = struct {
status: UvStatus,
local: UvLocalAction,
send_table: SendTableOp,
};
/// §9.1.1 L2083-2086 — the per-request catalog-emission dedup latch; one
/// instance covers one request.
pub const ServerUvRequest = struct {
catalog_emitted: bool = false,
};
/// §9.1.1 L2095-2113 steps 1-5 — the raw status code, before revert
/// mapping. `remote_hash` is the wire HASH (40/64 hex, or "-" for a
/// remote deletion).
pub fn uvStatus(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8) UvStatus {
std.debug.assert(remote_hash.len >= 1 and remote_hash.len <= 64);
const l = local orelse return .absent;
if (l.hash) |h| std.debug.assert(h.len <= 64);
const local_hash: []const u8 = l.hash orelse "-";
const mtime_cmp = std.math.order(l.mtime, remote_mtime);
const hash_cmp = std.mem.order(u8, local_hash, remote_hash);
const result: UvStatus = if (hash_cmp == .eq)
switch (mtime_cmp) {
.lt => .older,
.eq => .identical,
.gt => .newer,
}
else if (mtime_cmp == .lt or (mtime_cmp == .eq and hash_cmp == .lt))
.remote_wins
else
.local_wins;
std.debug.assert(@intFromEnum(result) <= 5);
return result;
}
/// §9.1.1 L2115-2117 — revert mode remaps 4->2 and 5->1; every other
/// status is unchanged.
fn revertMap(s: UvStatus) UvStatus {
return switch (s) {
.newer => .older,
.local_wins => .remote_wins,
else => s,
};
}
/// §9.1.1 L2156-2160 — the send-table effect of an already-mapped status;
/// a pull-only session never keeps an upload queued.
fn sendTableOp(status: UvStatus, pull_only: bool) SendTableOp {
const result: SendTableOp = switch (status) {
.absent, .remote_wins, .older, .identical => .remove,
.newer => if (pull_only) .remove else .mtime_only,
.local_wins => if (pull_only) .remove else .keep_full,
};
std.debug.assert(!pull_only or (result != .mtime_only and result != .keep_full));
return result;
}
/// §9.1.1 L2133-2154, L2118-2119 — the local-store effect of an
/// already-mapped status. `remote_is_deletion` is HASH == "-" on the wire;
/// dry-run suppresses every local change.
fn uvLocalAction(status: UvStatus, remote_is_deletion: bool, dry_run: bool) UvLocalAction {
if (dry_run) return .none;
const result: UvLocalAction = switch (status) {
.absent => if (remote_is_deletion) .none else .fetch,
.remote_wins => if (remote_is_deletion) .record_deletion else .fetch,
.older => .touch_mtime,
.identical, .newer, .local_wins => .none,
};
std.debug.assert(!dry_run or result == .none);
return result;
}
/// §9.1.1 — the deep composed interface: raw status, revert-mapped at most
/// once, then both its local-store and send-table effects computed over
/// the mapped value.
pub fn uvDecide(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8, mode: UvMode, pull_only: bool) UvDecision {
const raw = uvStatus(local, remote_mtime, remote_hash);
const status = if (mode.revert) revertMap(raw) else raw;
const remote_is_deletion = std.mem.eql(u8, remote_hash, "-");
const decision: UvDecision = .{
.status = status,
.local = uvLocalAction(status, remote_is_deletion, mode.dry_run),
.send_table = sendTableOp(status, pull_only),
};
std.debug.assert(!mode.dry_run or decision.local == .none);
std.debug.assert(@intFromEnum(decision.status) <= 5);
return decision;
}
/// §9.1.1 L2061-2063 — pure civil-from-unix conversion (UTC, no leap
/// seconds), matching SQLite's `datetime(mtime,'unixepoch')` output
/// exactly: fixed-width `YYYY-MM-DD HH:MM:SS`. Civil-date math is
/// Hinnant's `civil_from_days` (proleptic Gregorian, floor division
/// throughout so negative `mtime` stays correct) — never `std.time`, so
/// the result is a pure function of `mtime`.
fn formatUvDatetime(mtime: i64, buf: *[19]u8) []const u8 {
const secs_per_day: i64 = 86400;
const days = @divFloor(mtime, secs_per_day);
const secs_of_day = @mod(mtime, secs_per_day);
std.debug.assert(secs_of_day >= 0 and secs_of_day < secs_per_day);
// Hinnant civil_from_days: days since 1970-01-01 -> proleptic Gregorian
// y/m/d (http://howardhinnant.github.io/date_algorithms.html). era/doe
// split uses floor division so doe always lands in [0, 146096],
// keeping every later division non-negative regardless of sign(days).
const z = days + 719468;
const era = @divFloor(z, 146097);
const doe = z - era * 146097; // [0, 146096]
const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365); // [0, 399]
const y = yoe + era * 400;
const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); // [0, 365]
const mp = @divTrunc(5 * doy + 2, 153); // [0, 11]
const d = doy - @divTrunc(153 * mp + 2, 5) + 1; // [1, 31]
const m = mp + (if (mp < 10) @as(i64, 3) else @as(i64, -9)); // [1, 12]
const year = y + (if (m <= 2) @as(i64, 1) else @as(i64, 0));
std.debug.assert(year >= 0 and year <= 9999);
std.debug.assert(m >= 1 and m <= 12);
std.debug.assert(d >= 1 and d <= 31);
const hours = @divTrunc(secs_of_day, 3600);
const minutes = @divTrunc(@mod(secs_of_day, 3600), 60);
const seconds = @mod(secs_of_day, 60);
// Written field-by-field (no std.fmt.bufPrint) so there is no fallible
// path at all — width is fixed and every value is pre-bounded above.
writeFixedDigits(buf[0..4], @intCast(year));
buf[4] = '-';
writeFixedDigits(buf[5..7], @intCast(m));
buf[7] = '-';
writeFixedDigits(buf[8..10], @intCast(d));
buf[10] = ' ';
writeFixedDigits(buf[11..13], @intCast(hours));
buf[13] = ':';
writeFixedDigits(buf[14..16], @intCast(minutes));
buf[16] = ':';
writeFixedDigits(buf[17..19], @intCast(seconds));
return buf[0..19];
}
/// Writes `value` as `buf.len` zero-padded ASCII digits, most significant
/// first. `value` must fit in `buf.len` digits (asserted, not truncated).
fn writeFixedDigits(buf: []u8, value: u32) void {
var v = value;
for (0..buf.len) |k| {
const i = buf.len - 1 - k;
buf[i] = '0' + @as(u8, @intCast(v % 10));
v /= 10;
}
std.debug.assert(v == 0);
}
/// §9.1.1 L2061-2067 — the unversioned catalog hash: SHA-1 [RFC3174] over,
/// in name order, `NAME " " DATETIME " " HASH "\n"` for every row whose
/// hash is not null (a deleted row is excluded, L2064-2067). `rows` must
/// already be name-sorted — the store owns that ordering, not re-derived
/// here.
pub fn uvCatalogHash(rows: []const UvRow) HashHex {
var hasher = std.crypto.hash.Sha1.init(.{});
var prev: ?[]const u8 = null;
for (rows) |r| {
std.debug.assert(r.name.len <= max_line_len - 1);
if (prev) |p| std.debug.assert(std.mem.order(u8, p, r.name) != .gt);
prev = r.name;
const h = r.hash orelse continue;
std.debug.assert(h.len <= 64);
var dt_buf: [19]u8 = undefined;
const dt = formatUvDatetime(r.mtime, &dt_buf);
hasher.update(r.name);
hasher.update(" ");
hasher.update(dt);
hasher.update(" ");
hasher.update(h);
hasher.update("\n");
}
var digest: [20]u8 = undefined;
hasher.final(&digest);
const hex = sha1FinalHex(digest);
for (hex) |c| std.debug.assert((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'));
return hex;
}
/// §9.1.1 L2083-2086 — wraps `serverUvResponse` with the per-request
/// duplicate-catalog latch: once a catalog has gone out this request, a
/// later call on the same `state` yields `.none` regardless of inputs.
pub fn serverUvCatalog(state: *ServerUvRequest, match: bool, has_read: bool, may_upload: bool) UvResponse {
const already = state.catalog_emitted;
const result: UvResponse = if (already) .none else serverUvResponse(match, has_read, may_upload);
state.catalog_emitted = already or (result != .none);
std.debug.assert(!already or result == .none);
return result;
}
/// §3.1 L374-399, §9.1.1 L2054-2056 — the client's per-session unversioned
/// send table: NAME -> mtimeOnly. Owns duped NAME keys, so entries outlive
/// the per-round wire buffers that named them (mirrors `RoundTables`).
pub const UvSendTable = struct {
map: std.StringHashMapUnmanaged(bool) = .empty,
/// §9.1.1 L2054-2056 — session start: every local row whose hash is
/// not null seeds the table with mtimeOnly false.
pub fn initFromLocal(gpa: std.mem.Allocator, rows: []const UvRow) error{OutOfMemory}!UvSendTable {
var self: UvSendTable = .{};
errdefer self.deinit(gpa);
try self.map.ensureUnusedCapacity(gpa, @intCast(rows.len));
for (rows) |r| {
if (r.hash == null) continue;
std.debug.assert(r.name.len <= max_line_len - 1);
if (self.map.contains(r.name)) continue;
const owned = try gpa.dupe(u8, r.name);
self.map.putAssumeCapacity(owned, false);
}
return self;
}
pub fn deinit(self: *UvSendTable, gpa: std.mem.Allocator) void {
var it = self.map.keyIterator();
while (it.next()) |key| gpa.free(key.*);
self.map.deinit(gpa);
self.* = undefined;
}
/// §9.1.1 L2156-2160 — apply one status's send-table effect for NAME.
pub fn apply(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, op: SendTableOp) error{OutOfMemory}!void {
std.debug.assert(name.len <= max_line_len - 1);
switch (op) {
.remove => if (self.map.fetchRemove(name)) |kv| gpa.free(kv.key),
.mtime_only => try self.put(gpa, name, true),
.keep_full => try self.put(gpa, name, false),
}
std.debug.assert(self.contains(name) == (op != .remove));
}
fn put(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, mtime_only: bool) error{OutOfMemory}!void {
if (self.map.getPtr(name)) |v| {
v.* = mtime_only;
return;
}
try self.map.ensureUnusedCapacity(gpa, 1);
const owned = try gpa.dupe(u8, name);
self.map.putAssumeCapacity(owned, mtime_only);
}
pub fn contains(self: *const UvSendTable, name: []const u8) bool {
return self.map.contains(name);
}
pub fn mtimeOnly(self: *const UvSendTable, name: []const u8) ?bool {
return self.map.get(name);
}
pub fn isEmpty(self: *const UvSendTable) bool {
return self.map.count() == 0;
}
pub fn count(self: *const UvSendTable) usize {
return self.map.count();
}
};
test "sync §9.1.1: uv-hash datetime segment is SQLite datetime(mtime,'unixepoch')" {
var buf1: [19]u8 = undefined;
try std.testing.expectEqualStrings("1970-01-01 00:00:00", formatUvDatetime(0, &buf1));
var buf2: [19]u8 = undefined;
try std.testing.expectEqualStrings("2009-02-13 23:31:30", formatUvDatetime(1234567890, &buf2));
}
test "sync §9.1.1: status 0 fetches (uvgimme + drop replaced) unless HASH is -" {
try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.absent, false, false));
try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.absent, true, false));
}
test "sync §9.1.1: status 1 fetches when HASH present, records deletion when -" {
try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.remote_wins, false, false));
try std.testing.expectEqual(UvLocalAction.record_deletion, uvLocalAction(.remote_wins, true, false));
}
test "sync §9.1.1: status 2 updates local mtime only" {
try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, false, false));
try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, true, false));
}
test "sync §9.1.1: status 3 makes no local change" {
try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, false, false));
try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, true, false));
}
test "sync §9.1.1: status 4 keeps send-table entry mtimeOnly true when uploads allowed" {
try std.testing.expectEqual(SendTableOp.mtime_only, sendTableOp(.newer, false));
}
test "sync §9.1.1: status 5 keeps/reinserts send-table entry mtimeOnly false when uploads allowed" {
try std.testing.expectEqual(SendTableOp.keep_full, sendTableOp(.local_wins, false));
}
test "sync §9.1.1: send-table update — statuses 0-3 remove, 4 mtimeOnly, 5 keeps full" {
const Case = struct { status: UvStatus, want: SendTableOp };
const cases = [_]Case{
.{ .status = .absent, .want = .remove },
.{ .status = .remote_wins, .want = .remove },
.{ .status = .older, .want = .remove },
.{ .status = .identical, .want = .remove },
.{ .status = .newer, .want = .mtime_only },
.{ .status = .local_wins, .want = .keep_full },
};
for (cases) |c| try std.testing.expectEqual(c.want, sendTableOp(c.status, false));
}
test "sync §9.1.1: pull-only removes statuses 4 and 5 from the send table" {
try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.newer, true));
try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.local_wins, true));
}
/// §9.1.1-uv1 — the receiver's answer to a uvgimme: uvfile carrying
/// content when it fits the send-size budget, or uvigot re-announcing
/// the same state when it does not. The budget check itself (§4 body
/// cap) lives in the response assembler; this takes its verdict.
pub const UvGimmeReply = enum { uvfile, uvigot };
/// §9.1.1-uv1, §7.3 — "The receiver answers with uvfile ... or with
/// uvigot ... again if the send-size limit prevents including the
/// content in that response."
pub fn uvGimmeReply(content_fits: bool) UvGimmeReply {
return if (content_fits) .uvfile else .uvigot;
}
// ---------------------------------------------------------------------------
// Section 9.2.1 — Derived Branch, Tag, Leaf, Closed, and Current-Trunk Views
// ---------------------------------------------------------------------------
//
// §9.2.1 defines no wire bytes: it delegates branch/tag/leaf/closed/trunk
// views entirely to the artifact-format tag vocabulary and repostate's
// derivation, and states the readiness precondition under which a receiver
// may trust those views. Every mechanism it names already exists elsewhere
// (Operator's catalog, repostate's crosslink/resolveName/currentTrunk); this
// section is covered as cited tests over that existing structure, not new
// production code.
// ---------------------------------------------------------------------------
// Section 9.3-9.5 — Round-loop cluster (binding over §3.3 / §3.7)
// ---------------------------------------------------------------------------
//
// §9.3-9.5 restate no algorithm: they import the §3.3 phantom/igot/gimme
// surface (self-declared authoritative, L599-604) and the §3.7 termination
// rule. These tests bind that import over the already-committed predicates;
// the only production addition is `PendingDeltaQueue.hasTarget`, the OPTION B
// content-received derivation for the §3.3 dangling-delta path.
// The default filter set: nothing excludes the phantom.
// ---------------------------------------------------------------------------
// Section 3.2 — Reply consumption (Client.consumeReply); Appendix A worked
// exchanges A.1 (push/pull convergence), A.2 (SHA3 gate error card), A.4
// (comment lines). Reply bodies only — request bodies are the emission batch.
// ---------------------------------------------------------------------------
// §A.1 L3085-3090 — Round 1 reply, 367-byte body: server-version pragma, two
// gimme cards, two igot cards, closing timestamp. The vector holds raw wire
// bytes; the citation lives here, never inside the .txt.
const a1_round1_reply = @embedFile("rfc_vectors/sync_a1_round1_reply.txt");
// §A.1 L3141-3152 — Round 2 reply, 533-byte body: server-version pragma, two
// full-content file cards (270- and 31-byte payloads), closing timestamp.
const a1_round2_reply = @embedFile("rfc_vectors/sync_a1_round2_reply.txt");
// §A.2 L3206-3209 — SHA3 gate response, 191-byte body: error, clone_seqno 0,
// push, closing timestamp.
const a2_reply = @embedFile("rfc_vectors/sync_a2_reply.txt");
comptime {
// The RFC states these body sizes directly (L3074, L3126, L3204); a
// transcription drift trips here before any test runs.
std.debug.assert(a1_round1_reply.len == 367);
std.debug.assert(a1_round2_reply.len == 533);
std.debug.assert(a2_reply.len == 191);
}
// §A.1 hashes: server igot -> client phantom (L3088-3089); server gimme ->
// peer-lacks (L3086-3087); the ancestor the server already holds (L3092).
const a1_phantom_1 = "4361252d0f450879dcd2e0b6a42b95ffe8a41d8e948130253ae8eefa7f4b79ea";
const a1_phantom_2 = "f5116517775075ea483901021282ba97eebe5a2b993a47eb78198adc10efe128";
const a1_gimme_1 = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
const a1_gimme_2 = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15";
const a1_held_ancestor = "c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57";
test "sync §A.1: round-1 reply creates two phantoms from the server igot cards" {
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 = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
// L3093-3094 — two new phantoms 4361252d…/f5116517…, keyed by interned rid.
try std.testing.expectEqual(@as(usize, 2), client.phantoms.count());
try std.testing.expect(obs.phantom_created);
const rid1 = try ms.store().intern(a1_phantom_1);
const rid2 = try ms.store().intern(a1_phantom_2);
try std.testing.expect(client.phantoms.contains(rid1));
try std.testing.expect(client.phantoms.contains(rid2));
}
test "sync §A.1: server does not gimme the held ancestor; gimme records peer-lacks" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// L4099-4100 — the client announced igot for the two gimme'd hashes, so it
// holds them; pre-store so the round-1 gimme cards queue them for upload.
const rid1 = try ms.store().put(a1_gimme_1, "manifest payload\n");
const rid2 = try ms.store().put(a1_gimme_2, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
// (b) — the two server gimme cards (L4108-4109) queue outbound content.
try std.testing.expect(obs.content_queued);
try std.testing.expectEqual(@as(usize, 2), client.send_queue.count());
try std.testing.expect(client.send_queue.contains(rid1));
try std.testing.expect(client.send_queue.contains(rid2));
// The exact peer-lacks set is the two gimme'd hashes; the ancestor
// c5b2e0ec… is absent because the server already holds it (L4114) and so
// never gimme's it back — igotDisposition ignores an already-held hash.
var rt = RoundTables.init(std.testing.allocator);
defer rt.deinit();
try rt.recordPeerLacks(a1_gimme_1);
try rt.recordPeerLacks(a1_gimme_2);
try std.testing.expect(rt.peerLacks(a1_gimme_1));
try std.testing.expect(rt.peerLacks(a1_gimme_2));
try std.testing.expect(!rt.peerLacks(a1_held_ancestor));
try std.testing.expectEqual(IgotDisposition.ignore, igotDisposition(.client, .sync, true));
}
test "sync §A.1: both §3.7 conditions hold after round 1, exchange continues" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// Client holds the two gimme'd hashes (L4099-4100), so (b) fires.
_ = try ms.store().put(a1_gimme_1, "manifest payload\n");
_ = try ms.store().put(a1_gimme_2, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
try std.testing.expect(obs.phantom_table_nonempty and obs.phantom_created); // (a)
try std.testing.expect(obs.content_queued); // (b)
try std.testing.expect(continueExchange(obs));
try std.testing.expectEqual(ClientState.exchange, client.state);
}
test "sync §A.1: round-2 delivers two full-content files and empties the phantom set" {
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 client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply);
// Both file cards are full-content (two-argument) form (L3123-3124); the
// stated payload sizes are 270 (L3142) and 31 (L3150).
const rid1 = try ms.store().intern(a1_phantom_1);
const rid2 = try ms.store().intern(a1_phantom_2);
try std.testing.expectEqual(@as(usize, 270), ms.store().content(rid1).?.len);
try std.testing.expectEqual(@as(usize, 31), ms.store().content(rid2).?.len);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(obs.versioned_content_delivered and !obs.phantom_created);
}
test "sync §A.1: no round 3 — the client terminates after round 2" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// Round 1 queues the two held gimmes; round 2's reply carries no gimme.
_ = try ms.store().put(a1_gimme_1, "manifest payload\n");
_ = try ms.store().put(a1_gimme_2, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply);
// L4179-4182 — round 2 delivered content but its reply had no gimme, so
// nothing was appended this round: the per-round file-send count is zero.
try std.testing.expect(!obs.content_queued);
// The per-session buffer still holds the two rids queued in round 1 (§3.1
// L605-608), yet §3.7(b) reads the per-round count, not the buffer.
try std.testing.expectEqual(@as(usize, 2), client.send_queue.count());
// §3.7 holds nowhere prospectively; the client stops.
try std.testing.expect(!continueExchange(obs));
try std.testing.expectEqual(ClientState.terminated, client.state);
}
test "sync §A.2: clone reply discards the error, records seqno and push, advances" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(std.testing.allocator);
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a2_reply);
// L3217 — clone_seqno 0 = exhausted RID range.
try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next);
// L3208, §8.3/§11.1 — the project code arrives in the push card.
const push = parsePushPull(tokenizeCard("push 34302acad64fe2397bd907704dec457899e2f9a3 c85e3efddf993e86e8325d08672ea94bdba6c2c4")).?;
try std.testing.expectEqualStrings("c85e3efddf993e86e8325d08672ea94bdba6c2c4", push.projectcode);
// r14 — a processed first clone reply advances to EXCHANGE, not aborted
// (L3221-3224: the round-0 error is discarded, not fatal).
try std.testing.expectEqual(ClientState.exchange, client.state);
}
test "sync §A.2: cards after the discarded error are still dispatched (non-reset)" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a2_reply);
// The closing timestamp line (L3209) follows error/clone_seqno/push; a
// stop at the error card would leave it unseen (L3212-3216, no reset).
try std.testing.expect(client.last_timestamp != null);
try std.testing.expectEqualStrings("2026-07-02T18:26:49", client.last_timestamp.?.datetime);
try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next);
}
test "sync §A.4: both server replies surface the closing timestamp comment" {
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 client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
// L3439 — round-1 reply closes with the timestamp subform.
try std.testing.expectEqualStrings("2026-07-02T18:31:13", client.last_timestamp.?.datetime);
try std.testing.expectEqual(@as(u64, 0), client.last_timestamp.?.errors);
_ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply);
// L3443 — round-2 reply closes with the same subform, N = 0.
try std.testing.expectEqualStrings("2026-07-02T18:31:13", client.last_timestamp.?.datetime);
try std.testing.expectEqual(@as(u64, 0), client.last_timestamp.?.errors);
}
fn consumeReplyOom(gpa: std.mem.Allocator) !void {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
// Round 1 fills the phantom set (two igot) and peer-lacks (two gimme); no
// card in it is fatal or unimplemented, so OOM is the only non-success.
_ = client.consumeReply(gpa, ms.store(), a1_round1_reply) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.FatalProtocolError, error.NotImplemented => unreachable,
};
}
test "sync §3.2: consumeReply covers OOM paths (phantom set + peer-lacks fill)" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeReplyOom, .{});
}
// §7.5 — an arbitrary 64-hex hash for the private-marker tests. No expected
// byte derives from it; the store keys under the literal name and the asserted
// value (private_content_delivered) comes from the r1/r4 rules alone.
const p75_hash = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0";
test "sync §7.5 r1: a private card marks the next file card as private" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L2359-2360 — "A private card … marks the next … file or cfile card as
// carrying private content."
const body = "private\nfile " ++ p75_hash ++ " 1\nx";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!client.private_pending); // r4 — consumed by the file card
}
test "sync §7.5 r2: intervening comment/non-content cards do not consume the marker" {
// L2361-2362 — "intervening comment lines and other non-content cards do
// not consume it."
const intervening = [_][]const u8{ "# a comment\n", "pragma foo bar\n" };
for (intervening) |line| {
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 body = try std.mem.concat(std.testing.allocator, u8, &.{ "private\n", line });
defer std.testing.allocator.free(body);
_ = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(client.private_pending); // still set — nothing consumed it
}
}
test "sync §7.5 r3: a repeated private card leaves the marker set" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L2362-2363 — "a repeated private card leaves the flag set."
_ = try client.consumeReply(std.testing.allocator, ms.store(), "private\nprivate\n");
try std.testing.expect(client.private_pending);
}
test "sync §7.5 r4: the marker is consumed when the next cfile card handling begins" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// Build the §4.1 container in-test (uncompressed size 1 for the byte "y").
const container = try zlibContainerEncode(std.testing.allocator, "y", .default);
defer std.testing.allocator.free(container);
const header = try std.fmt.allocPrint(std.testing.allocator, "private\ncfile {s} 1 {d}\n", .{ p75_hash, container.len });
defer std.testing.allocator.free(header);
const body = try std.mem.concat(std.testing.allocator, u8, &.{ header, container });
defer std.testing.allocator.free(body);
// L2363-2364 — "the flag is consumed only when handling of the next file or
// cfile card begins."
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!client.private_pending);
}
test "sync §7.5 r5: the pending marker persists across reply bodies in a session" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L2364-2366 — "A client receiver carries the pending marker across
// successive reply bodies within a single sync session." Body 1 keeps the
// round alive so a 2nd reply is legal: the igot creates a phantom (§3.7(a)
// → round_continues → state stays .exchange), and being a non-content card
// it does not consume the marker (r2).
const body1 = "private\nigot " ++ a1_phantom_1 ++ "\n";
_ = try client.consumeReply(std.testing.allocator, ms.store(), body1);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(client.private_pending); // carried into the next body
// Body 2 delivers the announced artifact; that file card consumes the marker.
const body2 = "file " ++ a1_phantom_1 ++ " 1\nx";
const obs2 = try client.consumeReply(std.testing.allocator, ms.store(), body2);
try std.testing.expect(obs2.private_content_delivered);
try std.testing.expect(!client.private_pending);
}
test "sync §7.5 r6: a pending marker does not carry across the session boundary" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// L2366-2367 — "only a subsequent file or cfile card, or the end of that
// session, clears it." A fresh Client (new session) starts clear; a marker
// left pending in a prior session never binds a content card in a new one.
var c1 = Client{ .operation = .sync, .state = .exchange };
_ = try c1.consumeReply(std.testing.allocator, ms.store(), "private\n");
try std.testing.expect(c1.private_pending);
try std.testing.expectEqual(ClientState.terminated, c1.state);
c1.deinit(std.testing.allocator);
var c2 = Client{ .operation = .sync, .state = .exchange };
defer c2.deinit(std.testing.allocator);
try std.testing.expect(!c2.private_pending); // new session starts clear
const body = "file " ++ p75_hash ++ " 1\nx";
const obs = try c2.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(!obs.private_content_delivered); // no leak from session 1
}
test "sync §7.5 r7: a reply body ending with the marker still pending is not fatal (rejection)" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L2369-2371 — "Reaching the end of a client reply body with the marker
// still pending is not by itself a fatal protocol error." The body returns
// an observation (no error) and ends in the ordinary .terminated round end,
// not .aborted.
_ = try client.consumeReply(std.testing.allocator, ms.store(), "private\n# c\n");
try std.testing.expect(client.private_pending);
try std.testing.expectEqual(ClientState.terminated, client.state);
}
test "sync §7.5 r8: a private card carrying an argument is a fatal card-arity abort (rejection)" {
// r8 — "a private card carrying any argument is a §5.2 card-arity violation
// and a fatal strict-accept abort (§3.9)." ABNF L4643 fixes arity 1; §5.2
// L1770-1776 (defined operator + bad arg count → its own arity rule) and
// L1809-1813 (the client aborts the round, no outbound error card).
const bad = [_][]const u8{ "private x\n", "private a b\n" };
for (bad) |body| {
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.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expect(!client.private_pending); // never set on the arity-fail path
}
}
// Emit one random card-shaped or garbage line into `out`; returns bytes
// written (0 when there is no room). Menu spans every wire-parsing branch the
// dispatcher gained: gimme/igot/clone_seqno/pragma/timestamp/message/file/
// private plus raw garbage.
fn fuzzWriteLine(rand: std.Random, out: []u8) usize {
if (out.len < 96) return 0;
var hash: [64]u8 = undefined;
for (&hash) |*c| c.* = "0123456789abcdef"[rand.intRangeLessThan(u8, 0, 16)];
const h = hash[0..(if (rand.boolean()) @as(usize, 40) else 64)];
const line: []u8 = switch (rand.intRangeLessThan(u8, 0, 11)) {
0 => std.fmt.bufPrint(out, "gimme {s}\n", .{h}) catch return 0,
1 => std.fmt.bufPrint(out, "igot {s}\n", .{h}) catch return 0,
2 => std.fmt.bufPrint(out, "igot {s} 1\n", .{h}) catch return 0,
3 => std.fmt.bufPrint(out, "clone_seqno {d}\n", .{rand.int(i32)}) catch return 0,
4 => std.fmt.bufPrint(out, "pragma server-version {d} 20260311 113146\n", .{rand.int(u16)}) catch return 0,
5 => std.fmt.bufPrint(out, "# timestamp 2026-07-02T18:31:13 errors {d}\n", .{rand.int(u8)}) catch return 0,
6 => std.fmt.bufPrint(out, "message {s}\n", .{h[0..8]}) catch return 0,
// §7.5 — the private marker arm: bare (arity 1, valid) and argument-
// carrying (arity >1, a §5.2 fatal abort). Both must be tolerated: a
// declared error or a valid observation, never a trap.
8 => std.fmt.bufPrint(out, "private\n", .{}) catch return 0,
9 => std.fmt.bufPrint(out, "private {s}\n", .{h[0..rand.intRangeAtMost(usize, 1, 8)]}) catch return 0,
7 => blk: {
const glen = rand.intRangeAtMost(usize, 0, 16);
for (out[0..glen]) |*c| c.* = rand.int(u8);
out[glen] = '\n';
break :blk out[0 .. glen + 1];
},
else => blk: {
const size = rand.intRangeAtMost(u32, 0, 8);
const head = std.fmt.bufPrint(out, "file {s} {d}\n", .{ h, size }) catch return 0;
var w = head.len;
var k: u32 = 0;
while (k < size and w < out.len) : (k += 1) {
out[w] = rand.int(u8);
w += 1;
}
break :blk out[0..w];
},
};
return line.len;
}
fn fuzzReplyBody(rand: std.Random, buf: []u8) []const u8 {
// Half the runs corrupt a copy of the round-1 reply (payload-independent
// bit flips); the rest build a random card/garbage sequence.
if (rand.boolean()) {
const n = @min(buf.len, a1_round1_reply.len);
@memcpy(buf[0..n], a1_round1_reply[0..n]);
var flips = rand.intRangeAtMost(usize, 0, 6);
while (flips > 0) : (flips -= 1) buf[rand.intRangeLessThan(usize, 0, n)] ^= rand.int(u8);
return buf[0..n];
}
var len: usize = 0;
var lines = rand.intRangeAtMost(usize, 0, 12);
while (lines > 0 and len < buf.len) : (lines -= 1) len += fuzzWriteLine(rand, buf[len..]);
return buf[0..len];
}
test "sync §3.2 fuzz: consumeReply tolerates adversarial reply bytes" {
// The dispatcher must return only a valid RoundObservation or a declared
// ConsumeError — never trap — on any wire input, and the reported
// phantom_table_nonempty must always match the final phantom set (§3.7(a)).
var prng = std.Random.DefaultPrng.init(0x5CA1_AB1E_D00D_1E5E);
const rand = prng.random();
var buf: [1024]u8 = undefined;
var iter: usize = 0;
const iterations = 4000;
while (iter < iterations) : (iter += 1) {
const body = fuzzReplyBody(rand, &buf);
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| switch (e) {
error.OutOfMemory, error.FatalProtocolError, error.NotImplemented => continue,
};
try std.testing.expectEqual(client.phantoms.count() > 0, obs.phantom_table_nonempty);
// §7.5 L2359 — private content is only ever bound by a content card, so a
// delivered private marker implies versioned content this round.
if (obs.private_content_delivered) try std.testing.expect(obs.versioned_content_delivered);
}
}