//! 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);
}