//! 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");
/// 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;
}
/// §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 !(op == .clone and round == 0);
}
/// §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);
}