//! 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");
/// 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: one method produces the next
// request bytes, another consumes a reply's bytes and updates `state`; the
// transport (real HTTP or the simulator) is owned by the caller — the server
// is stateless, only the client keeps state (Section 3.8). The exact
// signatures land with the sync implementation, read from the full Section 3
// round / dispatch / termination rules. Deliberately not stubbed here so
// nothing is built against a placeholder shape.
};
test "client starts in init" {
const c: Client = .{};
try std.testing.expectEqual(ClientState.init, c.state);
}
// ---------------------------------------------------------------------------
// 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 };
}
test "sync §5.1: message body lines are LF-separated" {
const body = "a\nb\n";
const first = try readLine(body, 0);
try std.testing.expectEqualStrings("a", first.bytes);
try std.testing.expectEqual(@as(usize, 2), first.next);
const second = try readLine(body, first.next);
try std.testing.expectEqualStrings("b", second.bytes);
try std.testing.expectEqual(@as(usize, 4), second.next);
}
test "sync §5.1: each line is a card or a comment" {
try std.testing.expect(isCommentLine("#x"));
try std.testing.expect(!isCommentLine("push a b"));
}
test "sync §5.1: payload framed by byte count, not by scanning for LF" {
// "file h 5\n" is the card line; the declared payload is 5 bytes,
// "AB\nCD", which itself contains a LF that must not be scanned for.
const body = "file h 5\nAB\nCD";
const line = try readLine(body, 0);
try std.testing.expectEqualStrings("file h 5", line.bytes);
const payload = body[line.next .. line.next + 5];
try std.testing.expectEqualStrings("AB\nCD", payload);
}
test "sync §5.2: a card is operator plus zero or more argument tokens" {
try std.testing.expectEqual(@as(usize, 1), tokenizeCard("private").len);
try std.testing.expectEqual(@as(usize, 3), tokenizeCard("push a b").len);
}
test "sync §5.2: canonical wire form is single-space, no surrounding whitespace" {
const t = tokenizeCard("push a b");
try std.testing.expectEqual(@as(usize, 3), t.len);
try std.testing.expectEqualStrings("push", t.items[0]);
try std.testing.expectEqualStrings("a", t.items[1]);
try std.testing.expectEqualStrings("b", t.items[2]);
}
test "sync §5.2: operator token is lowercase letters plus underscore for clone_seqno" {
try std.testing.expectEqual(Operator.clone_seqno, parseOperator("clone_seqno").?);
try std.testing.expectEqual(Operator.push, parseOperator("push").?);
try std.testing.expect(parseOperator("PUSH") == null);
try std.testing.expect(parseOperator("push_") == null);
try std.testing.expect(parseOperator("cl0ne") == null);
}
test "sync §5.2: card tokenizes on Fossil-whitespace runs, LF is not a token byte" {
const t = tokenizeCard("push\ta\x0b\x0c\rb");
try std.testing.expectEqual(@as(usize, 3), t.len);
try std.testing.expectEqualStrings("push", t.items[0]);
try std.testing.expectEqualStrings("a", t.items[1]);
try std.testing.expectEqualStrings("b", t.items[2]);
}
test "sync §5.2: receiver accepts the canonical single-space form" {
const t = tokenizeCard("igot h 1");
try std.testing.expectEqual(@as(usize, 3), t.len);
try std.testing.expectEqualStrings("igot", t.items[0]);
try std.testing.expectEqualStrings("h", t.items[1]);
try std.testing.expectEqualStrings("1", t.items[2]);
}
test "sync §5.2: receiver accepts Fossil-whitespace runs (compat)" {
const t = tokenizeCard("push a\t\tb");
try std.testing.expectEqual(@as(usize, 3), t.len);
try std.testing.expectEqualStrings("push", t.items[0]);
try std.testing.expectEqualStrings("a", t.items[1]);
try std.testing.expectEqualStrings("b", t.items[2]);
}
test "sync §5.2: card and comment lines bounded at 65535 bytes including LF" {
var ok_buf: [max_line_len]u8 = undefined;
@memset(ok_buf[0 .. max_line_len - 1], 'x');
ok_buf[max_line_len - 1] = '\n';
const ok_line = try readLine(&ok_buf, 0);
try std.testing.expectEqual(@as(usize, max_line_len - 1), ok_line.bytes.len);
var over_buf: [max_line_len]u8 = undefined;
@memset(&over_buf, 'x');
try std.testing.expectError(LineError.MissingTerminator, readLine(&over_buf, 0));
}
test "sync §5.2: unknown card operator is a fatal unknown-operator disposition" {
const c = classifyCard("frobnicate x");
try std.testing.expectEqual(Disposition.unknown_operator, c.disposition);
try std.testing.expect(c.operator == null);
try std.testing.expect(isFatal(c.disposition, .client));
try std.testing.expect(isFatal(c.disposition, .server));
}
test "sync §5.2: defined operator with in-window arg count is dispatched not unknown" {
const c = classifyCard("private extra");
try std.testing.expectEqual(Operator.private, c.operator.?);
try std.testing.expectEqual(Disposition.dispatch, c.disposition);
}
test "sync §5.2: card with surplus tokens beyond the six-slot window is malformed" {
const t = tokenizeCard("push a b c d e f");
try std.testing.expectEqual(@as(usize, max_tokens), t.len);
try std.testing.expect(t.surplus);
const c = classifyCard("push a b c d e f");
try std.testing.expect(c.disposition != .dispatch);
}
test "sync §5.2: receiver treats surplus tokens as malformed" {
const simple = classifyCard("push a b c d e f");
try std.testing.expectEqual(Disposition.bad_command, simple.disposition);
const content = classifyCard("file a b c d e f");
try std.testing.expectEqual(Disposition.malformed_content, content.disposition);
}
test "sync §5.2: content card arity failure yields malformed CARD line and aborts" {
const c = classifyCard("file a b c d e f");
try std.testing.expectEqual(Disposition.malformed_content, c.disposition);
var out: [64]u8 = undefined;
const msg = writeMalformedCard(.file, &out);
try std.testing.expectEqualStrings("malformed file line", msg);
try std.testing.expect(isFatal(c.disposition, .client));
try std.testing.expect(isFatal(c.disposition, .server));
}
test "sync §5.2: simple card arity failure yields bad command, fatal client non-fatal server" {
const line = "push a b c d e f";
const c = classifyCard(line);
try std.testing.expectEqual(Disposition.bad_command, c.disposition);
var out: [64]u8 = undefined;
const msg = writeBadCommand(line, &out);
try std.testing.expectEqualStrings("bad command: push a b c d e f", msg);
try std.testing.expect(isFatal(c.disposition, .client));
try std.testing.expect(!isFatal(c.disposition, .server));
}
test "sync §5.2: unrecognized card framing is exactly 'bad command:' plus offending line" {
var out: [64]u8 = undefined;
const msg = writeBadCommand("frobnicate x y", &out);
try std.testing.expectEqualStrings("bad command: frobnicate x y", msg);
try std.testing.expect(std.mem.indexOf(u8, msg, "Unknown message") == null);
}
test "sync §5.2.1: defined operator catalog resolves client/server operators" {
try std.testing.expectEqual(Operator.cookie, parseOperator("cookie").?);
try std.testing.expectEqual(Operator.login, parseOperator("login").?);
try std.testing.expectEqual(Operator.push, parseOperator("push").?);
try std.testing.expectEqual(Operator.pull, parseOperator("pull").?);
}
test "sync §5.2.1: remaining operators resolve and catalog size is 19" {
const names = [_][]const u8{
"clone", "file", "cfile", "uvfile", "private",
"igot", "uvigot", "gimme", "uvgimme", "reqconfig",
"config", "clone_seqno", "pragma", "error", "message",
};
for (names) |name| {
try std.testing.expect(parseOperator(name) != null);
}
try std.testing.expectEqual(@as(usize, 19), @typeInfo(Operator).@"enum".fields.len);
}
test "sync §5.3: a line whose first byte is # is a comment line" {
try std.testing.expect(isCommentLine("#x"));
try std.testing.expect(!isCommentLine(""));
try std.testing.expect(!isCommentLine("push"));
}
test "sync §5.3: comment lines are not dispatched as cards" {
// A comment line may textually contain an operator name, but its
// leading # is what a card dispatcher sees as the first token, and
// "#" is not in the operator catalog -- so even a comment line that
// reads like a push card can never resolve to Operator.push
// (§5-r22).
try std.testing.expect(isCommentLine("# push a b"));
const c = classifyCard("# push a b");
try std.testing.expectEqual(Disposition.unknown_operator, c.disposition);
try std.testing.expect(c.operator == null);
}
test "sync §5.3: timestamp subform exposes DATETIME and informational error count" {
const tc = parseTimestampComment("# timestamp 2026-07-02T18:31:13 errors 0").?;
try std.testing.expectEqualStrings("2026-07-02T18:31:13", tc.datetime);
try std.testing.expectEqual(@as(u64, 0), tc.errors);
}
test "sync §5.3: other comment lines are not semantically interpreted" {
try std.testing.expect(parseTimestampComment("# F2A895EF75B47B7157664A7FEAC978EFFFDB01C4") == null);
try std.testing.expect(parseTimestampComment("# sending-clusters") == null);
try std.testing.expect(parseTimestampComment("# timestamp X errors zz") == null);
}
// ---------------------------------------------------------------------------
// 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.
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);
}
test "sync §10 r1: a pragma card is 'pragma NAME [ARGUMENTS]'" {
const c1 = classifyPragma(tokenizeCard("pragma send-catalog"));
try std.testing.expectEqual(PragmaName.@"send-catalog", c1.name.?);
const c2 = classifyPragma(tokenizeCard("pragma ci-lock H ID"));
try std.testing.expectEqual(PragmaName.@"ci-lock", c2.name.?);
}
test "sync §10 r2: an unrecognised pragma name is silently ignored" {
try std.testing.expect(parsePragmaName("future-x") == null);
const c = classifyPragma(tokenizeCard("pragma future-x arg"));
try std.testing.expect(c.name == null);
}
test "sync §10 r3: unknown pragma name is ignored but unknown card operator is fatal" {
const c = classifyPragma(tokenizeCard("pragma future-x"));
try std.testing.expect(c.name == null);
const bad = classifyCard("frobnicate x");
try std.testing.expectEqual(Disposition.unknown_operator, bad.disposition);
try std.testing.expect(isFatal(bad.disposition, .client));
try std.testing.expect(isFatal(bad.disposition, .server));
}
test "sync §10 r4: fourteen pragma names, nine c2s and five s2c" {
const Case = struct { name: []const u8, dir: PragmaDirection };
const cases = [_]Case{
.{ .name = "send-private", .dir = .client_to_server },
.{ .name = "send-catalog", .dir = .client_to_server },
.{ .name = "client-version", .dir = .client_to_server },
.{ .name = "uv-hash", .dir = .client_to_server },
.{ .name = "ci-lock", .dir = .client_to_server },
.{ .name = "ci-unlock", .dir = .client_to_server },
.{ .name = "client-url", .dir = .client_to_server },
.{ .name = "req-links", .dir = .client_to_server },
.{ .name = "req-clusters", .dir = .client_to_server },
.{ .name = "server-version", .dir = .server_to_client },
.{ .name = "uv-push-ok", .dir = .server_to_client },
.{ .name = "uv-pull-only", .dir = .server_to_client },
.{ .name = "ci-lock-fail", .dir = .server_to_client },
.{ .name = "avoid-delta-manifests", .dir = .server_to_client },
};
try std.testing.expectEqual(@as(usize, 14), cases.len);
var c2s: usize = 0;
var s2c: usize = 0;
for (cases) |c| {
const name = parsePragmaName(c.name).?;
try std.testing.expectEqual(c.dir, pragmaDirection(name));
switch (c.dir) {
.client_to_server => c2s += 1,
.server_to_client => s2c += 1,
}
}
try std.testing.expectEqual(@as(usize, 9), c2s);
try std.testing.expectEqual(@as(usize, 5), s2c);
}
test "sync §10 r5: pragma resolution is not version-gated" {
const names = [_][]const u8{
"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",
};
for (names) |name| {
try std.testing.expect(parsePragmaName(name) != null);
}
try std.testing.expect(parsePragmaName("unknown-thing") == null);
}
test "sync §10.1 r6: client-version is 'pragma client-version VERSION DATE TIME'" {
var buf: [64]u8 = undefined;
const v: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } };
try std.testing.expectEqualStrings("pragma client-version 20000 20260709 120000", try writeClientVersion(v, &buf));
}
test "sync §10.1 r7: VERSION = MAJOR*10000+MINOR*100+PATCH; 2.0 -> 20000" {
try std.testing.expectEqual(@as(u32, 20000), encodeVersion(2, 0, 0));
try std.testing.expectEqual(@as(u32, 19999), encodeVersion(1, 99, 99));
try std.testing.expect(encodeVersion(1, 99, 99) < 20000);
}
test "sync §10.1 r8: DATE and TIME are optional; VERSION-only is allowed" {
var buf: [64]u8 = undefined;
const v: VersionInfo = .{ .version = 20000 };
try std.testing.expectEqualStrings("pragma client-version 20000", try writeClientVersion(v, &buf));
const parsed = try parseVersionPragma(tokenizeCard("pragma client-version 20000"));
try std.testing.expect(parsed.datetime == null);
}
test "sync §10.1 r9: received client-version updates server peer version and timestamp" {
var p = Peer.serverInit();
recordPeerVersion(&p, .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } });
try std.testing.expectEqual(@as(u32, 20000), p.version);
try std.testing.expectEqual(@as(u32, 20260709), p.datetime.?.date);
try std.testing.expectEqual(@as(u32, 120000), p.datetime.?.time);
}
test "sync §10.1 r10: before client-version, server peer VERSION is 0" {
const p = Peer.serverInit();
try std.testing.expectEqual(@as(u32, 0), p.version);
try std.testing.expect(p.datetime == null);
}
test "sync §10.1 r11: server emits server-version iff pragma had DATE and TIME (>=5 tokens)" {
const v = try parseVersionPragma(tokenizeCard("pragma client-version 20000 20260709 120000"));
try std.testing.expect(serverEmitsServerVersion(v));
}
test "sync §10.1 r12: VERSION-only client-version suppresses server-version" {
const v = try parseVersionPragma(tokenizeCard("pragma client-version 20000"));
try std.testing.expect(!serverEmitsServerVersion(v));
}
test "sync §10.1 r13: received server-version updates client peer version and timestamp" {
var p = Peer.clientInit(19999);
recordPeerVersion(&p, .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } });
try std.testing.expectEqual(@as(u32, 20000), p.version);
try std.testing.expectEqual(@as(u32, 20260709), p.datetime.?.date);
}
test "sync §10.1 r14: before server-version, client peer VERSION is its own release integer" {
const p = Peer.clientInit(19999);
try std.testing.expectEqual(@as(u32, 19999), p.version);
try std.testing.expect(p.datetime == null);
}
test "sync §10.1: server-version DATE/TIME zero-padded to YYYYMMDD/HHMMSS form" {
var buf: [64]u8 = undefined;
const v1: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 90503 } };
try std.testing.expectEqualStrings("pragma server-version 20000 20260709 090503", try writeServerVersion(v1, &buf));
var buf2: [64]u8 = undefined;
const v2: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 1 } };
try std.testing.expectEqualStrings("pragma server-version 20000 20260709 000001", try writeServerVersion(v2, &buf2));
}
test "sync §10.1: client-version numeric fields must parse (rejection)" {
const cases = [_][]const u8{
"pragma client-version",
"pragma client-version x",
"pragma client-version 20000 x 120000",
"pragma client-version 20000 20260709 x",
};
for (cases) |line| {
try std.testing.expectError(PragmaError.Malformed, parseVersionPragma(tokenizeCard(line)));
}
}
test "sync §10.2 r15: uv-hash carries the client's catalog hash" {
const t = tokenizeCard("pragma uv-hash ABC123");
try std.testing.expectEqualStrings("ABC123", parseUvHash(t).?);
const c = classifyPragma(t);
try std.testing.expectEqual(PragmaName.@"uv-hash", c.name.?);
}
test "sync §10.2 r16: uv-hash sent at most once per session and never on clone round 0" {
const Case = struct { hash_sent: bool, op: Operation, round: u64, want: bool };
const cases = [_]Case{
.{ .hash_sent = false, .op = .sync, .round = 3, .want = true },
.{ .hash_sent = true, .op = .sync, .round = 3, .want = false },
.{ .hash_sent = false, .op = .clone, .round = 0, .want = false },
.{ .hash_sent = false, .op = .clone, .round = 1, .want = true },
};
for (cases) |c| {
try std.testing.expectEqual(c.want, clientMaySendUvHash(c.hash_sent, c.op, c.round));
}
}
test "sync §10.2 r17: matching catalog hash suppresses the uv response" {
try std.testing.expectEqual(UvResponse.none, serverUvResponse(true, true, true));
}
test "sync §10.2 r18: differing hash with read yields push-ok or pull-only" {
const Case = struct { match: bool, read: bool, upload: bool, want: UvResponse };
const cases = [_]Case{
.{ .match = false, .read = true, .upload = true, .want = .push_ok },
.{ .match = false, .read = true, .upload = false, .want = .pull_only },
.{ .match = false, .read = false, .upload = true, .want = .none },
};
for (cases) |c| {
try std.testing.expectEqual(c.want, serverUvResponse(c.match, c.read, c.upload));
}
}
test "sync §10.2 r19: uv-pull-only latches the client and push-ok cannot undo it" {
var s: UvSession = .{};
try std.testing.expectEqual(UvUpload.undetermined, s.upload);
clientOnUvPushOk(&s);
try std.testing.expectEqual(UvUpload.push_ok, s.upload);
clientOnUvPullOnly(&s);
try std.testing.expectEqual(UvUpload.pull_only, s.upload);
clientOnUvPushOk(&s);
try std.testing.expectEqual(UvUpload.pull_only, s.upload);
}
// ---------------------------------------------------------------------------
// 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 L2447-2449 — `pragma ci-lock-fail LOGIN MTIME` arguments.
pub const CiLockFail = struct { login: []const u8, mtime: u64 };
/// §10.4 r36 — LOGIN/MTIME off a dispatched `pragma ci-lock-fail` card;
/// null on a missing or non-decimal MTIME.
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 — 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 L2449-2450 — a client holding `ci-lock-fail` MUST NOT proceed
/// with the push it was attempting to lock for.
pub fn clientMayPushAfterCiLockFail(got_lock_fail: bool) bool {
return !got_lock_fail;
}
/// §10.4 L2452 — 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 — 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 L2452-2454 — 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);
}
test "sync §10.3 r20: reqconfig NAME must begin with /" {
try std.testing.expect(configNameHasSlashPrefix("/ui"));
try std.testing.expect(!configNameHasSlashPrefix("ui"));
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("reqconfig /ui", try writeReqconfig("/ui", &buf));
const name = parseReqconfigName(tokenizeCard("reqconfig /ui")).?;
try std.testing.expectEqualStrings("/ui", name);
}
test "sync §10.3 r21: server silently ignores reqconfig without Read, no error card" {
try std.testing.expectEqual(ReqconfigDisposition.ignore, serverReqconfigDisposition(false));
try std.testing.expectEqual(ReqconfigDisposition.reply, serverReqconfigDisposition(true));
}
test "sync §10.3 r22: clients defer reqconfig until after clone round 0" {
const Case = struct { op: Operation, round: u64, want: bool };
const cases = [_]Case{
.{ .op = .clone, .round = 0, .want = false },
.{ .op = .clone, .round = 1, .want = true },
.{ .op = .sync, .round = 0, .want = true },
.{ .op = .pull, .round = 3, .want = true },
};
for (cases) |c| {
try std.testing.expectEqual(c.want, sendableAfterCloneRound0(c.op, c.round));
}
}
test "sync §10.3 r23: config GROUPNAME SIZE card then SIZE content bytes" {
const c = try parseConfigCard(tokenizeCard("config /config 32"));
try std.testing.expectEqualStrings("/config", c.groupname);
try std.testing.expectEqual(@as(u32, 32), c.size);
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("config /config 32", try writeConfigCard("/config", 32, &buf));
const payload = try consumeConfigPayload("abc\n", 0, 3);
try std.testing.expectEqualStrings("abc", payload.bytes);
try std.testing.expectEqual(@as(usize, 4), payload.next);
}
test "sync §10.3 r24: scalar settings travel as /config, an encoding marker not a group id" {
try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
try std.testing.expect(std.mem.eql(u8, config_scalar_marker, "/config"));
}
test "sync §10.3 r25: scalar payload is MTIME SP QUOTED-NAME SP value SP QUOTED-VALUE" {
var buf: [64]u8 = undefined;
const written = try writeScalarConfig(1720483200, "autosync", "on", &buf);
try std.testing.expectEqualStrings("1720483200 'autosync' value 'on'", written);
const parsed = try parseScalarConfig(written);
try std.testing.expectEqual(@as(u64, 1720483200), parsed.mtime);
try std.testing.expectEqualStrings("'autosync'", parsed.quoted_name);
try std.testing.expectEqualStrings("'on'", parsed.quoted_value);
}
test "sync §10.3 r26: Fossil quote encoding wraps in ' and doubles internal '" {
var qbuf: [16]u8 = undefined;
try std.testing.expectEqualStrings("'it''s'", fossilQuote("it's", &qbuf));
var ubuf: [16]u8 = undefined;
try std.testing.expectEqualStrings("it's", try fossilUnquote("'it''s'", &ubuf));
var rbuf: [16]u8 = undefined;
const requoted = fossilQuote("a'b'c", &rbuf);
var rubuf: [16]u8 = undefined;
try std.testing.expectEqualStrings("a'b'c", try fossilUnquote(requoted, &rubuf));
var xbuf: [8]u8 = undefined;
try std.testing.expectEqualStrings("'x'", fossilQuote("x", &xbuf));
}
test "sync §10.3 r27: /config card name alone cannot identify the requested group" {
// Two conceptually distinct requests both surface under the same
// fixed marker: the byte carries no group information.
try std.testing.expectEqual(ConfigKind.scalar, configKind(config_scalar_marker));
try std.testing.expect(std.mem.eql(u8, config_scalar_marker, "/config"));
}
test "sync §10.3 r28: table entries travel as /GROUPNAME identifying the table" {
try std.testing.expectEqual(ConfigKind.table, configKind("/shun"));
try std.testing.expectEqual(ConfigKind.table, configKind("/user"));
try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
}
test "sync §10.3 r29: a single trailing newline follows each config payload and is consumed" {
const ok = try consumeConfigPayload("hi\nX", 0, 2);
try std.testing.expectEqualStrings("hi", ok.bytes);
try std.testing.expectEqual(@as(usize, 3), ok.next);
try std.testing.expectError(ConfigFrameError.MissingNewline, consumeConfigPayload("hiZ", 0, 2));
}
test "sync §10.3 r30: Admin may push a scalar config NAME SIZE card, same /config form" {
var payload_buf: [64]u8 = undefined;
const payload = try writeScalarConfig(1720483200, "autosync", "on", &payload_buf);
var card_buf: [32]u8 = undefined;
const card = try writeConfigCard("/config", @intCast(payload.len), &card_buf);
try std.testing.expectEqualStrings("config /config 32", card);
try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
}
test "sync §10.3 r31: server requires Admin to push; else error card, not applied (rejection)" {
try std.testing.expectEqual(ConfigPushDecision.reject, serverConfigPushDecision(false));
try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true));
try std.testing.expectEqualStrings("not authorized to push configuration", config_push_unauthorized_text);
}
test "sync §10.3 r32: pushed config card with negative SIZE is fatal (rejection)" {
try std.testing.expectError(ConfigError.NegativeSize, parseConfigCard(tokenizeCard("config /config -1")));
}
test "sync §10.3 r33: well-formed authorized push is applied; SIZE payload + newline consumed" {
try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true));
const payload = try consumeConfigPayload("on\nNEXT", 0, 2);
try std.testing.expectEqualStrings("on", payload.bytes);
try std.testing.expectEqual(@as(usize, 3), payload.next);
}
test "sync §10.3 r34: only the scalar /config form is pushed, not table groups" {
try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
try std.testing.expectEqual(ConfigKind.table, configKind("/shun"));
}
test "sync §10.3: quote/parse robustness negatives" {
var buf: [16]u8 = undefined;
try std.testing.expectError(QuoteError.Malformed, fossilUnquote("abc", &buf));
try std.testing.expectError(QuoteError.Malformed, fossilUnquote("'a'b'", &buf));
try std.testing.expectError(error.Malformed, parseScalarConfig("123 'n' notvalue 'v'"));
try std.testing.expectError(ConfigError.Malformed, parseConfigCard(tokenizeCard("config /config x")));
try std.testing.expectError(ConfigError.Malformed, parseConfigCard(tokenizeCard("config /config 5000000000")));
}
test "sync §10.4 r35: pragma ci-lock HASH CLIENT-ID" {
const lock = parseCiLock(tokenizeCard("pragma ci-lock abcdef0123 checkout-42")).?;
try std.testing.expectEqualStrings("abcdef0123", lock.hash);
try std.testing.expectEqualStrings("checkout-42", lock.client_id);
var buf: [48]u8 = undefined;
try std.testing.expectEqualStrings("pragma ci-lock abcdef0123 checkout-42", try writeCiLock("abcdef0123", "checkout-42", &buf));
}
test "sync §10.4 r36: lock held by a different client -> ci-lock-fail LOGIN MTIME (rejection)" {
try std.testing.expectEqual(CiLockResult.failed, serverCiLockResult("alice", "bob"));
try std.testing.expectEqual(CiLockResult.granted, serverCiLockResult("alice", "alice"));
try std.testing.expectEqual(CiLockResult.granted, serverCiLockResult(null, "bob"));
var buf: [48]u8 = undefined;
const written = try writeCiLockFail("alice", 1720483200, &buf);
try std.testing.expectEqualStrings("pragma ci-lock-fail alice 1720483200", written);
const parsed = parseCiLockFail(tokenizeCard(written)).?;
try std.testing.expectEqualStrings("alice", parsed.login);
try std.testing.expectEqual(@as(u64, 1720483200), parsed.mtime);
}
test "sync §10.4 r37: client receiving ci-lock-fail must not push" {
try std.testing.expect(!clientMayPushAfterCiLockFail(true));
try std.testing.expect(clientMayPushAfterCiLockFail(false));
}
test "sync §10.4 r38: pragma ci-unlock CLIENT-ID releases the lock" {
const id = parseCiUnlock(tokenizeCard("pragma ci-unlock checkout-42")).?;
try std.testing.expectEqualStrings("checkout-42", id);
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("pragma ci-unlock checkout-42", try writeCiUnlock("checkout-42", &buf));
}
test "sync §10.4 r39: known-id clients send ci-unlock in later rounds when no lock pending" {
const Case = struct { has_client_id: bool, lock_pending: bool, round: u64, want: bool };
const cases = [_]Case{
.{ .has_client_id = true, .lock_pending = false, .round = 3, .want = true },
.{ .has_client_id = true, .lock_pending = true, .round = 3, .want = false },
.{ .has_client_id = false, .lock_pending = false, .round = 3, .want = false },
.{ .has_client_id = true, .lock_pending = false, .round = 0, .want = false },
};
for (cases) |c| {
try std.testing.expectEqual(c.want, clientSendsCiUnlock(c.has_client_id, c.lock_pending, c.round));
}
}
test "sync §10.5 r40: pragma send-private is client-to-server, arg-less" {
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("pragma send-private", try writeFlagPragma(.@"send-private", &buf));
try std.testing.expectEqual(PragmaDirection.client_to_server, pragmaDirection(.@"send-private"));
}
test "sync §10.5 r41: server enables private sync only with the Private (x) capability" {
try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
try std.testing.expectEqual(SendPrivateDecision.reject, serverSendPrivateDecision(false));
}
test "sync §10.5 r42: unauthorized send-private emits a non-reset error card (rejection)" {
try std.testing.expectEqualStrings("not authorized to sync private content", send_private_unauthorized_text);
var buf: [128]u8 = undefined;
try std.testing.expectEqualStrings(
"error not\\sauthorized\\sto\\ssync\\sprivate\\scontent",
writeSignalCard(.err, send_private_unauthorized_text, &buf),
);
try std.testing.expectEqual(ErrorReset.append, serverErrorReset(.unauthorized_send_private));
}
test "sync §10.5 r43: send-private repeats every private round; the decision is idempotent" {
try std.testing.expectEqual(PragmaSchedule.every_private_round, pragmaSchedule(.@"send-private").?);
try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
}
test "sync §10.5 r44: pragma send-catalog requests every igot (full resync)" {
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("pragma send-catalog", try writeFlagPragma(.@"send-catalog", &buf));
}
test "sync §10.5 r45: pragma req-clusters requests cluster igot cards" {
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("pragma req-clusters", try writeFlagPragma(.@"req-clusters", &buf));
}
test "sync §10.5 r46: pragma req-links requests link-graph data" {
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("pragma req-links", try writeFlagPragma(.@"req-links", &buf));
}
test "sync §10.5 r47: pragma client-url communicates the client's canonical URL" {
var buf: [64]u8 = undefined;
try std.testing.expectEqualStrings("pragma client-url http://x/y", writeClientUrl("http://x/y", &buf));
var write_buf: [64]u8 = undefined;
const written = writeClientUrl("a b", &write_buf);
try std.testing.expectEqualStrings("pragma client-url a\\sb", written);
var decode_buf: [16]u8 = undefined;
const parsed = parseClientUrl(tokenizeCard(written), &decode_buf).?;
try std.testing.expectEqualStrings("a b", parsed);
}
test "sync §10.5 r48: client-url and req-links are sent once, before the round loop" {
try std.testing.expectEqual(PragmaSchedule.once_pre_loop, pragmaSchedule(.@"client-url").?);
try std.testing.expectEqual(PragmaSchedule.once_pre_loop, pragmaSchedule(.@"req-links").?);
}
test "sync §10.5 r49: pragma avoid-delta-manifests is server-to-client" {
try std.testing.expectEqual(PragmaDirection.server_to_client, pragmaDirection(.@"avoid-delta-manifests"));
var buf: [40]u8 = undefined;
try std.testing.expectEqualStrings("pragma avoid-delta-manifests", try writeFlagPragma(.@"avoid-delta-manifests", &buf));
}
test "sync §10.5: arg-less pragma set matches the ABNF" {
const Case = struct { name: PragmaName, argless: bool };
const cases = [_]Case{
.{ .name = .@"send-private", .argless = true },
.{ .name = .@"send-catalog", .argless = true },
.{ .name = .@"req-links", .argless = true },
.{ .name = .@"req-clusters", .argless = true },
.{ .name = .@"avoid-delta-manifests", .argless = true },
.{ .name = .@"uv-push-ok", .argless = true },
.{ .name = .@"uv-pull-only", .argless = true },
.{ .name = .@"client-version", .argless = false },
.{ .name = .@"server-version", .argless = false },
.{ .name = .@"uv-hash", .argless = false },
.{ .name = .@"ci-lock", .argless = false },
.{ .name = .@"ci-lock-fail", .argless = false },
.{ .name = .@"ci-unlock", .argless = false },
.{ .name = .@"client-url", .argless = false },
};
try std.testing.expectEqual(@as(usize, 14), cases.len);
for (cases) |c| {
try std.testing.expectEqual(c.argless, pragmaIsArgless(c.name));
}
}
// ---------------------------------------------------------------------------
// Section 11 — Error and Message Cards
// ---------------------------------------------------------------------------
/// §11.1 L2546-2547 (worked example, App. A.2) — 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 (r2, r10), 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 r9/r10, §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 L2500-2508, §11.2 r9/r10 L2552-2556 — 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 r10 — 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 r3/r7 — the client's disposition of a received `error` card.
pub const ClientErrorAction = enum { discard, terminate };
/// §11.1 r3 L2510-2513 (outside clone round 0: terminate) / r7 L2540-2542
/// (first round of a clone: silently discard) / r6 L2537-2538 (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 L2525-2536 — 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 L2525-2536 — 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 r4 L2525-2529 (worked example: wrong project, auth failure,
/// duplicate login, unknown operator -> reset) vs. r5 L2532-2536 (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 r12 L2560-2562, 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 r11/r12/r13 — a message card never terminates the session
/// (r11); text matching the pull-only pattern is handled silently
/// (r12); everything else is optionally displayable (r13).
pub fn clientOnMessageCard(decoded: []const u8) MessageAction {
return if (matchesPullOnly(decoded)) .pull_only_silent else .display_optional;
}
test "sync §11 r1: error card is 'error TEXT'" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("error abc", writeSignalCard(.err, "abc", &buf));
try std.testing.expectEqual(Operator.@"error", classifyCard("error abc").operator.?);
}
test "sync §11 r2: TEXT is fossilize-encoded (spaces as \\s); a receiver decodes it" {
var buf: [16]u8 = undefined;
const written = writeSignalCard(.err, "a b", &buf);
try std.testing.expectEqualStrings("error a\\sb", written);
var decode_buf: [8]u8 = undefined;
const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?;
try std.testing.expectEqualStrings("a b", decoded);
}
test "sync §11 r3: error card outside clone round 0 terminates the sync loop (rejection)" {
try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
}
test "sync §11 r4: fatal-error sites that reset prior output (worked example)" {
const sites = [_]ErrorSite{ .wrong_project, .auth_failure, .duplicate_login, .unknown_operator };
for (sites) |s| try std.testing.expectEqual(ErrorReset.reset, serverErrorReset(s));
}
test "sync §11 r5: append-only error sites keep prior response cards" {
const sites = [_]ErrorSite{ .peer_version_gate, .unauthorized_send_private, .private_card };
for (sites) |s| try std.testing.expectEqual(ErrorReset.append, serverErrorReset(s));
}
test "sync §11 r6: the error signal lives wholly in the body, not an HTTP status" {
// clientOnErrorCard's only input is ClientState -- no status parameter
// is in scope, matching "HTTP response status is 200" (L2537-2538).
try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}
test "sync §11 r7: the first round of a clone silently discards an error card (worked example)" {
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}
test "sync §11 r8: SHA3 peer-version gate text is 'Fossil version 2.0 or later required.' (worked example)" {
try std.testing.expectEqualStrings("Fossil version 2.0 or later required.", peer_version_error_text);
var buf: [96]u8 = undefined;
try std.testing.expectEqualStrings(
"error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.",
writeSignalCard(.err, peer_version_error_text, &buf),
);
}
test "sync §11 r9: message card is 'message TEXT', non-fatal" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("message hi", writeSignalCard(.message, "hi", &buf));
try std.testing.expectEqual(Operator.message, classifyCard("message hi").operator.?);
}
test "sync §11 r10: message TEXT is encoded identically to an error card" {
var buf: [16]u8 = undefined;
const written = writeSignalCard(.message, "a b", &buf);
try std.testing.expectEqualStrings("message a\\sb", written);
var decode_buf: [8]u8 = undefined;
const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?;
try std.testing.expectEqualStrings("a b", decoded);
}
test "sync §11 r11: a message card never terminates the session" {
try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard("hello"));
}
test "sync §11 r12: 'pull only *' silently clears push intent (rejection/silence)" {
try std.testing.expectEqual(MessageAction.pull_only_silent, clientOnMessageCard("pull only clone"));
try std.testing.expect(matchesPullOnly("pull only clone"));
try std.testing.expect(!matchesPullOnly("pull only"));
}
test "sync §11 r13: other message text may be displayed to the user (should)" {
try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard("hello world"));
try std.testing.expect(!matchesPullOnly("pull all now"));
try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard("pull all now"));
}
test "sync §11 fuzz: writeSignalCard/parseSignalText round-trip never crashes" {
// Seeded deterministic fuzz over untrusted wire input:
// arbitrary TEXT bytes survive write -> tokenize -> parse unchanged,
// and the encoded TEXT never splits into a second token, because every
// Fossil-whitespace/LF octet is a member of artifact's Table 1 escape
// set (proven by the artifact §4.3 fuzz test) and so never survives
// encoding unescaped.
var prng = std.Random.DefaultPrng.init(0x5E9A_11CA);
const rand = prng.random();
var round: usize = 0;
const max_len: usize = 32;
var text_buf: [max_len]u8 = undefined;
var card_buf: [8 + 2 * max_len]u8 = undefined;
var decode_buf: [2 * max_len]u8 = undefined;
while (round < 300) : (round += 1) {
const len = rand.uintLessThan(usize, max_len + 1);
for (text_buf[0..len]) |*b| b.* = rand.int(u8);
const text = text_buf[0..len];
const kind: SignalKind = if (rand.boolean()) .err else .message;
const written = writeSignalCard(kind, text, &card_buf);
const tokens = tokenizeCard(written);
try std.testing.expectEqual(@as(usize, if (len == 0) 1 else 2), tokens.len);
const decoded = parseSignalText(tokens, &decode_buf);
if (len == 0) {
try std.testing.expect(decoded == null);
} else {
try std.testing.expectEqualSlices(u8, text, decoded.?);
}
}
}
// ---------------------------------------------------------------------------
// 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 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;
}
};
test "sync §3.6: server emits 'cookie TEXT', TEXT opaque" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("cookie s3cr3t", writeCookieCard("s3cr3t", &buf));
const c = classifyCard("cookie s3cr3t");
try std.testing.expectEqual(Operator.cookie, c.operator.?);
try std.testing.expectEqual(Disposition.dispatch, c.disposition);
}
test "sync §3.6/§5.2: cookie TEXT is fossilize-encoded" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("cookie a\\sb", writeCookieCard("a b", &buf));
}
test "sync §3.6: client stores cookie, replacing any previously held" {
var store: CookieStore = .{};
store.store("v1");
store.store("v2");
try std.testing.expectEqualStrings("v2", store.value().?);
var decode_buf: [8]u8 = undefined;
const decoded = parseSignalText(tokenizeCard("cookie v2"), &decode_buf).?;
store.store(decoded);
try std.testing.expectEqualStrings("v2", store.value().?);
}
test "sync §3.6: client returns the stored cookie in a later request" {
var store: CookieStore = .{};
store.store("a b");
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("cookie a\\sb", writeCookieCard(store.value().?, &buf));
}
test "sync §3.6: server does not depend on a cookie card" {
const c = classifyCard("cookie x");
try std.testing.expectEqual(Operator.cookie, c.operator.?);
try std.testing.expectEqual(Disposition.dispatch, c.disposition);
try std.testing.expect(!isFatal(c.disposition, .server));
}
test "sync §3.6: server tolerates a cookie from a different server" {
const written = "cookie a\\sb";
const c = classifyCard(written);
try std.testing.expectEqual(Disposition.dispatch, c.disposition);
try std.testing.expect(!isFatal(c.disposition, .server));
var decode_buf: [8]u8 = undefined;
const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?;
try std.testing.expectEqualStrings("a b", decoded);
}
test "sync §3.6: server tolerates more than one cookie card in a request" {
const lines = [_][]const u8{ "cookie one", "cookie two", "cookie three" };
for (lines) |line| {
const c = classifyCard(line);
try std.testing.expectEqual(Disposition.dispatch, c.disposition);
try std.testing.expect(!isFatal(c.disposition, .server));
}
}
test "sync §3.6: a client that never returns a cookie is conformant" {
const store: CookieStore = .{};
try std.testing.expect(store.value() == null);
}
test "sync §3.6 fuzz: CookieStore store/emit round-trip never crashes" {
// Seeded deterministic fuzz over untrusted wire input: an arbitrary
// decoded cookie value survives store -> writeCookieCard -> tokenize ->
// parseSignalText unchanged, for the same reason the §11 signal-card
// round-trip holds (every Table 1 escape octet is covered).
var prng = std.Random.DefaultPrng.init(0xC00C_1E00);
const rand = prng.random();
var round: usize = 0;
const max_len: usize = 32;
var text_buf: [max_len]u8 = undefined;
var card_buf: [8 + 2 * max_len]u8 = undefined;
var decode_buf: [2 * max_len]u8 = undefined;
while (round < 300) : (round += 1) {
const len = rand.uintLessThan(usize, max_len + 1);
for (text_buf[0..len]) |*b| b.* = rand.int(u8);
const text = text_buf[0..len];
var store: CookieStore = .{};
store.store(text);
const written = writeCookieCard(store.value().?, &card_buf);
const tokens = tokenizeCard(written);
try std.testing.expectEqual(@as(usize, if (len == 0) 1 else 2), tokens.len);
const decoded = parseSignalText(tokens, &decode_buf);
if (len == 0) {
try std.testing.expect(decoded == null);
} else {
try std.testing.expectEqualSlices(u8, text, decoded.?);
}
}
}
// ---------------------------------------------------------------------------
// 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;
}
test "sync §7.6/§12.4: SHA3-256 artifact refused to peer version < 20000 (rejection)" {
const sha3_hex = "a" ** 64;
try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, sha3_hex));
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, sha3_hex));
}
test "sync §12.4: no silent downgrade to SHA1" {
const sha3_hex = "a" ** 64;
const sha1_hex = "a" ** 40;
try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, sha3_hex));
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, sha1_hex));
}
test "sync §12.4: gate has no truncate/re-identify path (rejection)" {
try std.testing.expectEqual(@as(usize, 2), @typeInfo(GateDecision).@"enum".fields.len);
const sha3_hex = "a" ** 64;
const sha1_hex = "a" ** 40;
const not_hex_64 = "z" ** 64;
try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, sha3_hex));
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, sha3_hex));
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, sha1_hex));
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, sha1_hex));
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, not_hex_64));
}
test "sync §10.1: gate is pure — unrecorded-peer default is the caller's, side-dependent" {
const sha3_hex = "a" ** 64;
// Server side: before any client-version pragma, VERSION is treated
// as 0 (L2317-2319) -> the caller passes 0 -> refuse.
try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(0, sha3_hex));
// Client side: before any server-version pragma, VERSION is treated
// as the client's own release integer (L2325-2327); a SHA3-capable
// client's own integer is >= 20000 -> the caller passes that -> emit.
const own_release: u32 = 20500;
try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(own_release, sha3_hex));
}
// ---------------------------------------------------------------------------
// 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 — outbound versioned content queued.
send_queue_nonempty: 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.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.send_queue_nonempty or // (b)
obs.private_discovery_pending or // (c)
continuesUnversionedCycle(obs) or // (d)
continuesClone(obs) or // (e)
obs.config_exchange_pending; // (f)
}
test "sync §3.7 r1: another round iff at least one of (a)-(f) holds" {
const base = RoundObservation{ .operation = .pull };
try std.testing.expect(!continueExchange(base));
var a = base;
a.versioned_content_delivered = true;
a.phantom_table_nonempty = true;
try std.testing.expect(continueExchange(a));
var b = base;
b.send_queue_nonempty = true;
try std.testing.expect(continueExchange(b));
var c = base;
c.private_discovery_pending = true;
try std.testing.expect(continueExchange(c));
var d = base;
d.uv_send_table_nonempty = true;
d.uv_upload_enabled = true;
try std.testing.expect(continueExchange(d));
const e = RoundObservation{ .operation = .clone, .first_clone_round = true };
try std.testing.expect(continueExchange(e));
var f = base;
f.config_exchange_pending = true;
try std.testing.expect(continueExchange(f));
}
test "sync §3.7 r2: versioned progress with non-empty phantom table continues (a)" {
const Case = struct { delivered: bool = false, created: bool = false, phantom_nonempty: bool, want: bool };
const cases = [_]Case{
.{ .delivered = true, .phantom_nonempty = true, .want = true },
.{ .created = true, .phantom_nonempty = true, .want = true },
.{ .delivered = true, .phantom_nonempty = false, .want = false }, // guard, L735-736
.{ .phantom_nonempty = true, .want = false }, // nothing progressed
};
for (cases) |tc| {
const obs = RoundObservation{
.operation = .pull,
.versioned_content_delivered = tc.delivered,
.phantom_created = tc.created,
.phantom_table_nonempty = tc.phantom_nonempty,
};
try std.testing.expectEqual(tc.want, continueExchange(obs));
}
}
test "sync §3.7 r3: non-empty send queue continues (b)" {
var obs = RoundObservation{ .operation = .push };
obs.send_queue_nonempty = true;
try std.testing.expect(continueExchange(obs));
}
test "sync §3.7 r4: private-content discovery round continues (c)" {
var obs = RoundObservation{ .operation = .sync };
obs.private_discovery_pending = true;
try std.testing.expect(continueExchange(obs));
}
test "sync §3.7 r5: unversioned send table + upload-enabled continues (d)(1)" {
const Case = struct { table_nonempty: bool, upload_enabled: bool, want: bool };
const cases = [_]Case{
.{ .table_nonempty = true, .upload_enabled = true, .want = true },
.{ .table_nonempty = true, .upload_enabled = false, .want = false },
.{ .table_nonempty = false, .upload_enabled = true, .want = false },
};
for (cases) |tc| {
const obs = RoundObservation{
.operation = .push,
.uv_send_table_nonempty = tc.table_nonempty,
.uv_upload_enabled = tc.upload_enabled,
};
try std.testing.expectEqual(tc.want, continueExchange(obs));
}
}
test "sync §3.7 r6: uvgimme with uvfile or round<3 continues (d)(2)" {
const Case = struct { queued: bool, uvfile: bool, round: u64, want: bool };
const cases = [_]Case{
.{ .queued = true, .uvfile = true, .round = 5, .want = true },
.{ .queued = true, .uvfile = false, .round = 2, .want = true },
.{ .queued = true, .uvfile = false, .round = 3, .want = false },
.{ .queued = false, .uvfile = false, .round = 0, .want = false },
};
for (cases) |tc| {
const obs = RoundObservation{
.operation = .push,
.uvgimme_queued = tc.queued,
.uvfile_received = tc.uvfile,
.session_round = tc.round,
};
try std.testing.expectEqual(tc.want, continueExchange(obs));
}
}
test "sync §3.7 r7: modern-clone continuation conditions (e)" {
const Case = struct {
op: Operation = .clone,
first: bool = false,
received: bool = false,
seqno: i64 = 0,
grew: bool = false,
want: bool,
};
const cases = [_]Case{
.{ .first = true, .want = true },
.{ .received = true, .want = true },
.{ .seqno = 5, .grew = true, .want = true },
.{ .seqno = 5, .grew = false, .want = false }, // non-advancing peer, L1915-1924
.{ .seqno = 0, .grew = true, .want = false },
.{ .op = .pull, .received = true, .seqno = 5, .grew = true, .want = false },
};
for (cases) |tc| {
const obs = RoundObservation{
.operation = tc.op,
.first_clone_round = tc.first,
.artifact_received_this_round = tc.received,
.clone_seqno_next = tc.seqno,
.artifact_count_grew = tc.grew,
};
try std.testing.expectEqual(tc.want, continueExchange(obs));
}
}
test "sync §3.7 r8: pending configuration exchange continues (f)" {
var obs = RoundObservation{ .operation = .sync };
obs.config_exchange_pending = true;
try std.testing.expect(continueExchange(obs));
}
test "sync §3.7 r9: phantoms remaining alone do not continue (rejection)" {
const obs = RoundObservation{ .operation = .pull, .phantom_table_nonempty = true };
try std.testing.expect(!continueExchange(obs));
}
test "sync §3.7 r10: no pending work in either direction stops" {
const obs = RoundObservation{ .operation = .sync };
try std.testing.expect(!continueExchange(obs));
try std.testing.expectEqual(ClientState.terminated, transition(.exchange, .round_terminates));
}
// ---------------------------------------------------------------------------
// 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 L2500-2513
project_code_mismatch, // §9.1 -> error card, §11.1 L2526-2528
auth_failure, // §6 -> error card, §11.1 L2526-2528
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 L2526-2528, §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;
}
test "sync §3.8 r11: the client occupies exactly five named states" {
const fields = @typeInfo(ClientState).@"enum".fields;
try std.testing.expectEqual(@as(usize, 5), fields.len);
const want = [_][]const u8{ "init", "clone_round0", "exchange", "terminated", "aborted" };
for (want) |name| {
var found = false;
inline for (fields) |f| {
if (std.mem.eql(u8, f.name, name)) found = true;
}
try std.testing.expect(found);
}
}
test "sync §3.8 r12: INIT + modern clone -> CLONE_ROUND0" {
try std.testing.expectEqual(ClientState.clone_round0, initialState(.clone));
}
test "sync §3.8 r13: INIT + push/pull/sync -> EXCHANGE" {
const ops = [_]Operation{ .push, .pull, .sync };
for (ops) |op| try std.testing.expectEqual(ClientState.exchange, initialState(op));
}
test "sync §3.8 r14: CLONE_ROUND0 reply processed -> EXCHANGE, error discarded" {
try std.testing.expectEqual(ClientState.exchange, transition(.clone_round0, .clone_reply_processed));
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}
test "sync §3.8 r15: CLONE_ROUND0 transport/redirect/neg-seqno -> ABORTED" {
try std.testing.expectEqual(ClientState.aborted, transition(.clone_round0, .clone_fatal));
try std.testing.expect(cloneSeqnoIsFatal(-1));
}
test "sync §3.8 r16: EXCHANGE + a 3.7 condition -> EXCHANGE" {
try std.testing.expectEqual(ClientState.exchange, transition(.exchange, .round_continues));
}
test "sync §3.8 r17: EXCHANGE + none of 3.7 -> TERMINATED" {
try std.testing.expectEqual(ClientState.terminated, transition(.exchange, .round_terminates));
}
test "sync §3.8 r18: EXCHANGE + fatal protocol error -> ABORTED" {
try std.testing.expectEqual(ClientState.aborted, transition(.exchange, .fatal_error));
}
test "sync §3.8 r19: nine fatal causes abort EXCHANGE (rejection)" {
const causes = [_]FatalCause{
.error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal,
.unknown_operator, .malformed_content, .over_length_line, .redirect_limit,
.clone_seqno_negative,
};
try std.testing.expectEqual(@as(usize, 9), causes.len);
for (causes) |c| try std.testing.expect(causeIsFatal(.exchange, c));
}
test "sync §3.8 r19: CLONE_ROUND0 tolerates error-card-delivered causes, aborts client-detected ones (rejection)" {
const tolerated = [_]FatalCause{ .error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal };
for (tolerated) |c| try std.testing.expect(!causeIsFatal(.clone_round0, c));
const fatal = [_]FatalCause{
.unknown_operator, .malformed_content, .over_length_line, .redirect_limit, .clone_seqno_negative,
};
for (fatal) |c| try std.testing.expect(causeIsFatal(.clone_round0, c));
try std.testing.expectEqual(@as(usize, 9), tolerated.len + fatal.len);
try std.testing.expect(cloneSeqnoIsFatal(-1));
try std.testing.expect(!cloneSeqnoIsFatal(0));
try std.testing.expect(!cloneSeqnoIsFatal(5));
}