Skip to content
zit Artifact [1ce8e0a60e]
Login

Artifact [1ce8e0a60e]

Artifact 1ce8e0a60eed76a1f591f788dca99b65951105eacb890a027c6458e6c7d6e9ac:


//! Sync Protocol — draft-fossil-sync-protocol-00.
//!
//! Two repositories reconcile a content-addressed artifact set over HTTP. The
//! protocol is client-driven; the server is stateless across rounds, so only
//! the client runs a state machine (Section 3.8).

const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const delta = @import("delta.zig");
const flate = std.compress.flate;

/// The client's protocol state (Section 3.8). The server reacts to each
/// request independently and has no state machine.
pub const ClientState = enum {
    /// Operation selected; no round sent yet.
    init,
    /// First round of a modern clone: login suppressed, config/uv-hash
    /// deferred, an `error` reply ignored (Sections 8.3, 8.4, 11.1).
    clone_round0,
    /// Steady round loop: authenticated request, reply dispatched, phantom /
    /// send-queue / config / private work advances (Section 3.2).
    exchange,
    /// Clean stop: no continuation condition of Section 3.7 held.
    terminated,
    /// A fatal protocol error ended the sync without convergence.
    aborted,
};

/// The operation a sync round is performing.
pub const Operation = enum { pull, push, sync, clone };

pub const Error = error{ FatalProtocolError, NotAuthorized, NotImplemented };

/// Robustness posture (Section 3.9): conservative in what it sends, strict in
/// what it accepts. An unknown non-`pragma` operator and a malformed content
/// card are fatal; liberality is confined to the four enumerated points.
pub const Client = struct {
    state: ClientState = .init,
    operation: Operation = .sync,

    // The round loop is a sans-IO state machine: 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.
/// §8.3 L1988-1993 gates the same round on the login side: round 0 knows
/// no project code yet, so both body-login and cookie-login transport
/// belong under this one predicate too. The request-assembly call site
/// that consults it for login gating is not yet written; only the
/// uv-hash call site (above) exists so far.
pub fn sendableAfterCloneRound0(op: Operation, round: u64) bool {
    return !(op == .clone and round == 0);
}

/// §10.2 r16 L2335-2337, §9.1.1 L2058-2059 — uv-hash goes out at most
/// once per session and never on clone round 0.
pub fn clientMaySendUvHash(hash_sent: bool, op: Operation, round: u64) bool {
    return !hash_sent and sendableAfterCloneRound0(op, round);
}

/// §10.2 r17/r18 L2339-2343 — the server's per-request unversioned-file
/// response.
pub const UvResponse = enum { none, push_ok, pull_only };

/// §10.2 r17/r18 L2339-2343, §9.1.1 L2077-2081 — matching catalog hash
/// or missing read permission yields no response; otherwise the server
/// grants push or pull-only depending on upload permission.
pub fn serverUvResponse(match: bool, has_read: bool, may_upload: bool) UvResponse {
    const result: UvResponse = blk: {
        if (match) break :blk .none;
        if (!has_read) break :blk .none;
        break :blk if (may_upload) .push_ok else .pull_only;
    };
    std.debug.assert(result != .push_ok or (!match and has_read and may_upload));
    std.debug.assert(result != .pull_only or (!match and has_read and !may_upload));
    std.debug.assert(result != .none or (match or !has_read));
    return result;
}

/// §9.1.1 L2088-2091, §10.2 L2359-2361 — the client's per-session
/// unversioned-upload state as a single enum, not two independent
/// bools: the r19 pull-only latch is one field and the contradictory
/// "push-ok AND pull-only" state is unrepresentable.
pub const UvUpload = enum { undetermined, push_ok, pull_only };

/// §3.1 L374-399 — mirrors the "catalog sent" flag and the collapsed
/// upload latch [rw-1] as the client's per-session unversioned state.
pub const UvSession = struct {
    hash_sent: bool = false,
    upload: UvUpload = .undetermined,
};

/// §9.1.1 L2088-2089 — pragma uv-push-ok sets upload-enabled, but MUST
/// NOT override an existing pull-only latch (r19, §10.2 L2359-2361):
/// once pull-only, a later push-ok is a no-op.
pub fn clientOnUvPushOk(s: *UvSession) void {
    const old = s.upload;
    if (s.upload != .pull_only) s.upload = .push_ok;
    std.debug.assert(if (old == .pull_only) s.upload == .pull_only else s.upload == .push_ok);
}

/// §9.1.1 L2089-2091, §10.2 r19 L2359-2361 — pragma uv-pull-only
/// latches the session unconditionally; it always wins.
pub fn clientOnUvPullOnly(s: *UvSession) void {
    s.upload = .pull_only;
    std.debug.assert(s.upload == .pull_only);
}

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 / §7.6 r5: error card outside clone round 0 terminates the sync loop (rejection)" {
    try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
}

// §7.6 append: this reset set is the contrast case — wrong_project /
// auth_failure / duplicate_login / unknown_operator reset, so append-only
// sites (below) can be seen as the exception the §7.6 gate falls under.
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 / §7.6 append & r3: 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 / §7.6 r5: 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 / §7.6 r2, textref, r7: 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 / §7.6 r3: 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));
}

// §7.6 r7: the 2-field GateDecision enum is a precondition value, not a
// card variant — the gate defines no new card syntax, only a yes/no on
// whether the caller may write the card it already knows how to write.
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));
}

// §7.6 r6: the gate applies at three independent send sites (file, cfile,
// uvfile) and a sender MUST apply it at each site independently. The gate
// itself is site-agnostic by design (L1782 folds the uvfile "64-character
// lowercase hexadecimal content hash" into the same HASH-shape test as the
// file/cfile name), so at the decision layer "independent per site" reduces
// to: the same pure function fires identically for three distinct 64-hex
// values, standing in for the file-name, cfile-name, and uvfile-hash forms.
// The three real call sites land with the future file/cfile/uvfile send
// batches (§7.1/§7.2/§7.3); this pins the decision those sites will share.
test "sync §7.6 r6: the one gate governs the file, cfile, and uvfile 64-hex forms" {
    const sites = [_][]const u8{
        "1" ** 64, // stands for a file-card HASH
        "2" ** 64, // stands for a cfile-card HASH
        "3" ** 64, // stands for a uvfile-card HASH field
    };
    const sha1_hex = "a" ** 40;
    for (sites) |hash| {
        try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, hash));
        try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, hash));
    }
    try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, sha1_hex));
}

// App. A.2 L3102-3114: the hand-transcribed 4-line gate-fire response body
// (Content-Length: 191), cited not machine-generated. zit owns no
// clone_seqno/# timestamp writer or multi-card response assembler (out of
// §7.6 scope), so this only pins the fixture's byte length and that the one
// card §7.6 owns -- the error card -- is byte-identical to the body's first
// line. Decoded-text/encoding equality is not re-asserted here (that's §11
// r8); this test is about the body shape, not the text content.
test "sync §7.6 App. A.2: gate-fire response body is 191 bytes; zit's error card is its first line" {
    const body =
        "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.\n" ++
        "clone_seqno 0\n" ++
        "push 34302acad64fe2397bd907704dec457899e2f9a3 c85e3efddf993e86e8325d08672ea94bdba6c2c4\n" ++
        "# timestamp 2026-07-02T18:26:49 errors 0\n";
    try std.testing.expectEqual(@as(usize, 191), body.len);

    var buf: [96]u8 = undefined;
    const err_card = writeSignalCard(.err, peer_version_error_text, &buf);
    const first_line_end = std.mem.indexOfScalar(u8, body, '\n').?;
    try std.testing.expectEqualStrings(err_card, body[0..first_line_end]);
}

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

// ---------------------------------------------------------------------------
// Section 3.1 / 3.3 — Round Tables & Phantom Lifecycle
// ---------------------------------------------------------------------------

/// §3.1 L411-417, r26/r27 — the two per-round tracking tables (Table 1: peer-
/// has, peer-lacks). Both are created-or-reset at round start and torn down
/// at round end (r20/r21, L433-436); they own no state past that boundary.
/// The string maps do not own their keys, so `reset`/`deinit` free the
/// `gpa.dupe`'d hashes explicitly, mirroring `MemStore` (repostate.zig).
pub const RoundTables = struct {
    gpa: std.mem.Allocator,
    peer_has: std.StringHashMapUnmanaged(void) = .empty,
    peer_lacks: std.StringHashMapUnmanaged(void) = .empty,

    pub fn init(gpa: std.mem.Allocator) RoundTables {
        return .{ .gpa = gpa };
    }

    pub fn deinit(self: *RoundTables) void {
        freeKeys(self.gpa, &self.peer_has);
        self.peer_has.deinit(self.gpa);
        freeKeys(self.gpa, &self.peer_lacks);
        self.peer_lacks.deinit(self.gpa);
        self.* = undefined;
    }

    /// r20/r21, L433-436 — create-or-reset at a round boundary: free every
    /// owned key and empty both tables, keeping bucket storage (capacity is
    /// bounded by one round's hash count).
    pub fn reset(self: *RoundTables) void {
        freeKeys(self.gpa, &self.peer_has);
        self.peer_has.clearRetainingCapacity();
        freeKeys(self.gpa, &self.peer_lacks);
        self.peer_lacks.clearRetainingCapacity();
        std.debug.assert(self.peer_has.count() == 0 and self.peer_lacks.count() == 0);
    }

    /// r26, L630-633 — record a hash the peer announced or sent this round.
    pub fn recordPeerHas(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void {
        try recordInto(self.gpa, &self.peer_has, hash);
        std.debug.assert(self.peerHas(hash));
    }

    /// r27, L633-637 — record a hash the peer requested with gimme this round.
    pub fn recordPeerLacks(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void {
        try recordInto(self.gpa, &self.peer_lacks, hash);
        std.debug.assert(self.peerLacks(hash));
    }

    pub fn peerHas(self: *const RoundTables, hash: []const u8) bool {
        return self.peer_has.contains(hash);
    }

    pub fn peerLacks(self: *const RoundTables, hash: []const u8) bool {
        return self.peer_lacks.contains(hash);
    }

    fn freeKeys(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void)) void {
        var it = map.keyIterator();
        while (it.next()) |key| gpa.free(key.*);
    }

    /// Reserves capacity on `map` itself before dupe-ing `hash`, so the
    /// trailing insert is `putAssumeCapacity` (infallible) and an OOM can
    /// only happen before any owned memory exists — no partial state, no
    /// errdefer needed. A repeat record of an already-held key returns
    /// before the dupe, so it never leaks a duplicate key.
    fn recordInto(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void), hash: []const u8) error{OutOfMemory}!void {
        std.debug.assert(hash.len == 40 or hash.len == 64);
        if (map.contains(hash)) return;
        try map.ensureUnusedCapacity(gpa, 1);
        const owned = try gpa.dupe(u8, hash);
        map.putAssumeCapacity(owned, {});
    }
};

const test_hash_a = "a" ** 40;
const test_hash_b = "b" ** 40;

test "sync §3.1 r20: per-round tables reset at round boundaries" {
    var rt = RoundTables.init(std.testing.allocator);
    defer rt.deinit();
    try rt.recordPeerHas(test_hash_a);
    try rt.recordPeerLacks(test_hash_b);
    rt.reset();
    try std.testing.expectEqual(@as(usize, 0), rt.peer_has.count());
    try std.testing.expectEqual(@as(usize, 0), rt.peer_lacks.count());
}

test "sync §3.1 r21: per-round tables carry no state across rounds" {
    var rt = RoundTables.init(std.testing.allocator);
    defer rt.deinit();
    try rt.recordPeerHas(test_hash_a);
    try std.testing.expect(rt.peerHas(test_hash_a));
    rt.reset();
    try std.testing.expect(!rt.peerHas(test_hash_a));
}

test "sync §3.3 r26: peer-has records hashes the peer announced or sent" {
    var rt = RoundTables.init(std.testing.allocator);
    defer rt.deinit();
    try rt.recordPeerHas(test_hash_a);
    try std.testing.expect(rt.peerHas(test_hash_a));
    try std.testing.expect(!rt.peerHas(test_hash_b));
}

test "sync §3.3 r27: peer-lacks records hashes the peer requested with gimme" {
    var rt = RoundTables.init(std.testing.allocator);
    defer rt.deinit();
    try rt.recordPeerLacks(test_hash_a);
    try std.testing.expect(rt.peerLacks(test_hash_a));
    try std.testing.expect(!rt.peerLacks(test_hash_b));
}

fn roundTablesNoLeak(gpa: std.mem.Allocator) !void {
    var rt = RoundTables.init(gpa);
    defer rt.deinit();
    try rt.recordPeerHas(test_hash_a);
    try rt.recordPeerLacks(test_hash_b);
    // Repeat record of an already-held key must not leak the fresh dupe.
    try rt.recordPeerHas(test_hash_a);
    rt.reset();
}

test "sync §3.3: round tables record* under allocation failure" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, roundTablesNoLeak, .{});
}

/// §3.3 r24(1), L621-625 — whether `side` can obtain content in operation
/// `op`. Sync is bidirectional, so it appears on both arms.
pub fn igotCanObtain(side: Side, op: Operation) bool {
    return switch (side) {
        .client => switch (op) {
            .pull, .clone, .sync => true,
            .push => false,
        },
        .server => switch (op) {
            .push, .sync => true,
            .pull, .clone => false,
        },
    };
}

pub const IgotDisposition = enum { create_phantom, ignore };

/// §3.3 r24(1), L621-625 — igot creates a phantom iff the receiver does not
/// already hold the artifact and is in an operation state that can obtain it.
pub fn igotDisposition(side: Side, op: Operation, already_holds: bool) IgotDisposition {
    const result: IgotDisposition = if (!already_holds and igotCanObtain(side, op)) .create_phantom else .ignore;
    std.debug.assert(result != .create_phantom or (!already_holds and igotCanObtain(side, op)));
    return result;
}

test "sync §3.3 r25: igot creates a phantom only when unheld and obtainable" {
    const Case = struct { side: Side, op: Operation, already_holds: bool, want: IgotDisposition };
    const cases = [_]Case{
        .{ .side = .client, .op = .pull, .already_holds = false, .want = .create_phantom },
        .{ .side = .client, .op = .pull, .already_holds = true, .want = .ignore },
        .{ .side = .client, .op = .clone, .already_holds = false, .want = .create_phantom },
        .{ .side = .client, .op = .push, .already_holds = false, .want = .ignore },
        .{ .side = .server, .op = .push, .already_holds = false, .want = .create_phantom },
        .{ .side = .server, .op = .pull, .already_holds = false, .want = .ignore },
        .{ .side = .client, .op = .sync, .already_holds = false, .want = .create_phantom },
        .{ .side = .server, .op = .sync, .already_holds = false, .want = .create_phantom },
    };
    for (cases) |c| try std.testing.expectEqual(c.want, igotDisposition(c.side, c.op, c.already_holds));
}

/// §3.3 (a)/(b)/(c), L644-652 — the freshly-per-round gimme eligibility
/// filter inputs.
pub const Eligibility = struct {
    peer_lacks: bool,
    shunned: bool,
    private: bool,
    private_sync_enabled: bool,
};

/// §3.3, L644-652 — order (a) peer-lacks, (b) shunned, (c) private-without-
/// sync-enabled; a phantom is gimme-eligible only if none reject it.
pub fn gimmeEligible(e: Eligibility) bool {
    if (e.peer_lacks) return false;
    if (e.shunned) return false;
    if (e.private and !e.private_sync_enabled) return false;
    const result = true;
    std.debug.assert(!e.peer_lacks and !e.shunned);
    return result;
}

test "sync §3.3 (a): peer-lacks hashes are excluded from gimme (rejection)" {
    try std.testing.expect(!gimmeEligible(.{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false }));
    try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = false, .private_sync_enabled = false }));
}

test "sync §3.3 (b): shunned hashes are excluded from gimme (rejection)" {
    try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = true, .private = false, .private_sync_enabled = false }));
}

test "sync §3.3 (c): private hashes excluded unless private sync enabled (rejection)" {
    try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false }));
    try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true }));
}

test "sync §3.3: receiving gimme records the peer lacks the hash" {
    var rt = RoundTables.init(std.testing.allocator);
    defer rt.deinit();
    try rt.recordPeerLacks(test_hash_a);
    try std.testing.expect(rt.peerLacks(test_hash_a));
    try std.testing.expect(!gimmeEligible(.{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false }));
}

pub const GimmeResponse = enum { send, silent_skip };

/// §3.3, L654-662 — inbound-gimme disposition. Caller records peer-lacks
/// (r27) before calling. `.send` only when held, direction permits sending,
/// and policy permits disclosure; every other case is a silent skip — no
/// content, no error card, no deferred send (r34/r35 and the policy-denied
/// case fold to the same byte-neutral outcome since none emits a card).
pub fn gimmeResponse(holds: bool, direction_permits: bool, policy_permits: bool) GimmeResponse {
    const result: GimmeResponse = if (holds and direction_permits and policy_permits) .send else .silent_skip;
    std.debug.assert(result != .send or (holds and direction_permits and policy_permits));
    return result;
}

test "sync §3.3: held + sendable + disclosable content is sent" {
    try std.testing.expectEqual(GimmeResponse.send, gimmeResponse(true, true, true));
    try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, true, false));
}

test "sync §3.3: held but direction forbids sending is a silent no-send (rejection)" {
    try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, true));
    try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, false));
}

test "sync §3.3: gimme for unheld content is silently skipped (rejection)" {
    try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, true, true));
    try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, false, false));
}

// §3.3 r24: a phantom is a hash known with content absent — this is
// `ArtifactStore`'s own contract (`content(rid) == null`), not a sync-layer
// mechanism. No `isPhantom` wrapper enters this file; the definition is
// asserted directly against the store below (store-contract dependency
// test, not sync coverage).
test "sync §3.3 r24: a phantom is a hash known with content absent (store contract)" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();
    const rid = try store.intern(test_hash_a);
    try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid));
    _ = try store.put(test_hash_a, "payload");
    try std.testing.expect(store.content(rid) != null);
}

// §3.3 r36: storing content dephantomizes — again the store's own contract,
// asserted directly (store-contract dependency test, not sync coverage).
test "sync §3.3 r36: storing content dephantomizes (store contract)" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();
    const rid = try store.intern(test_hash_b);
    try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid));
    _ = try store.put(test_hash_b, "content");
    try std.testing.expect(store.content(rid) != null);
}

/// §3.2 r22/r23, L455-473 — request-card classification for the ordering
/// rule. `content_request` covers clone and gimme (L471); `uvgimme` is
/// unversioned and deferred. `send_private`/`login` classify as `.other`
/// (L458-463), so the two transport/order exceptions fall out of the
/// ordering scan below without special-casing them.
pub const RequestCard = enum { client_version, content_request, other };

pub const RequestOrder = enum { ok, missing_client_version, version_after_content_request };

/// §3.2, L455-473 — a request is well-ordered iff it carries a
/// client-version pragma and no content-requesting card precedes it. An
/// empty body has no client-version card, so it is itself the r22
/// rejection — no length assertion, no panic (an empty request is legal
/// input, not a caller bug).
pub fn checkRequestOrder(cards: []const RequestCard) RequestOrder {
    var version_index: ?usize = null;
    var content_index: ?usize = null;
    for (cards, 0..) |card, i| {
        switch (card) {
            .client_version => if (version_index == null) {
                version_index = i;
            },
            .content_request => if (content_index == null) {
                content_index = i;
            },
            .other => {},
        }
    }
    const result: RequestOrder = if (version_index == null)
        .missing_client_version
    else if (content_index != null and content_index.? < version_index.?)
        .version_after_content_request
    else
        .ok;
    std.debug.assert(result != .ok or (version_index != null and (content_index == null or content_index.? > version_index.?)));
    return result;
}

test "sync §3.2 r22: a request missing client-version is rejected" {
    try std.testing.expectEqual(RequestOrder.missing_client_version, checkRequestOrder(&.{}));
    try std.testing.expectEqual(RequestOrder.missing_client_version, checkRequestOrder(&.{.content_request}));
    try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&.{.client_version}));
}

test "sync §3.2 r23: client-version must precede content-requesting cards" {
    try std.testing.expectEqual(RequestOrder.version_after_content_request, checkRequestOrder(&.{ .content_request, .client_version }));
    try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&.{ .client_version, .content_request }));
}

test "sync §3.2 r23: send-private/login before client-version is accepted" {
    try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&.{ .other, .client_version, .content_request }));
}

// ---------------------------------------------------------------------------
// Section 8 — Clone
// ---------------------------------------------------------------------------

/// §8.1 L1867-1876 — the sole interoperable clone transfer path this
/// document defines. Orthogonal to the release integer carried in
/// version pragmas (L1881-1885): the 20000 threshold in `sha3PeerVersionGate`
/// names a Fossil release, never this transfer-path version.
pub const clone_version: u8 = 3;

/// §8.1 L1867 — write `clone VERSION SEQNO`. SEQNO is a blob-table row
/// identifier (§8.2 L1889), always non-negative on the request side (a
/// negative NEXT received in a reply is fatal before it is ever
/// re-sent, per `cloneSeqnoIsFatal`).
pub fn writeCloneCard(seqno: u64, out: []u8) std.fmt.BufPrintError![]u8 {
    const needed = std.fmt.count("clone {d} {d}", .{ clone_version, seqno });
    std.debug.assert(needed <= 28); // "clone 3 " + max u64 digits, ceiling
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "clone {d} {d}", .{ clone_version, seqno });
}

test "sync §8.1: clone card carries VERSION SEQNO, VERSION decimal 3" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &buf));
    try std.testing.expectEqual(@as(u8, 3), clone_version);
}

test "sync §8.1: modern clone request and x-fossil-uncompressed reply (worked example)" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("clone 3 7", try writeCloneCard(7, &buf));
    // Server-side cfile emission over storage-sequence order needs an
    // ArtifactStore iterator and is deferred; the reply media type it
    // uses is already on record here.
    const props = media_props.get(.x_fossil_uncompressed);
    try std.testing.expect(props.clone_reply_only);
    try std.testing.expect(!props.outer_compressed);
    try std.testing.expectEqualStrings("application/x-fossil-uncompressed", props.wire);
}

test "sync §8.1: clone version 3 orthogonal to the §7.6 release-integer gate" {
    var buf: [32]u8 = undefined;
    try std.testing.expect(@as(u32, clone_version) != sha3_min_peer_version);
    try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &buf));
    // A release well below the SHA3 gate's floor still speaks clone 3.
    const sha3_hex = "a" ** 64;
    try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(15000, sha3_hex));
}

/// §8.2 L1896-1937 — parse the reply's `clone_seqno NEXT` card. A
/// present-but-non-integer NEXT is the sole real parse failure; a
/// negative value parses cleanly here and is instead the fatal-cause
/// path's job (`cloneSeqnoIsFatal`), not a rejection at this layer.
pub fn parseCloneSeqno(tokens: Tokens) error{Malformed}!i64 {
    if (tokens.len < 2) return error.Malformed;
    return std.fmt.parseInt(i64, tokens.items[1], 10) catch error.Malformed;
}

/// §8.2 L1965-1983 loop-tail — the shape of the next request, given
/// whether Section 3.7 continues the exchange and the NEXT just
/// received. `next > 0` sends another clone card; a live continuation
/// with NEXT already exhausted is a cleanup round (no clone card, L1930-
/// 1934); no continuation stops. The MUST-NOT-after-0 rule (L1927-1928)
/// falls out structurally: `.clone_card` is unreachable once NEXT is 0.
pub const NextRequest = enum { clone_card, cleanup_round, stop };

pub fn nextRequestKind(continuing: bool, clone_seqno_next: i64) NextRequest {
    std.debug.assert(clone_seqno_next >= 0); // negative NEXT aborts before this is reached
    if (!continuing) return .stop;
    return if (clone_seqno_next > 0) .clone_card else .cleanup_round;
}

test "sync §8.2: continuing clone request carries clone 3 NEXT" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqual(NextRequest.clone_card, nextRequestKind(true, 5));
    try std.testing.expectEqualStrings("clone 3 5", try writeCloneCard(5, &buf));
}

test "sync §8.2: client MUST NOT send a clone card after clone_seqno 0 (rejection)" {
    try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, 0));
    try std.testing.expect(NextRequest.clone_card != nextRequestKind(true, 0));
}

test "sync §8.2: a cleanup round omits the clone card; empty work terminates" {
    try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, 0));
    try std.testing.expectEqual(NextRequest.stop, nextRequestKind(false, 0));
}

/// §8.2 L1917-1921 — the server's matching liveness invariant: a
/// positive NEXT is only legal when the round it ends also advanced the
/// sequence. The store-backed cfile emission path that this predicate
/// guards is deferred (needs an ArtifactStore/blob-table iterator); this
/// is the pure invariant that path will assert against.
pub fn cloneServerMustAdvance(clone_seqno_next: i64, unsent_at_or_above_next: bool) bool {
    std.debug.assert(clone_seqno_next >= 0);
    return clone_seqno_next > 0 and unsent_at_or_above_next;
}

test "sync §8.2: server MUST advance when NEXT>0 and RIDs>=NEXT unsent (rejection)" {
    const Case = struct { next: i64, unsent: bool, want: bool };
    const cases = [_]Case{
        .{ .next = 5, .unsent = true, .want = true },
        .{ .next = 10, .unsent = false, .want = false },
        .{ .next = 0, .unsent = true, .want = false },
    };
    for (cases) |c| try std.testing.expectEqual(c.want, cloneServerMustAdvance(c.next, c.unsent));
}

test "sync §8.2: modern clone loop (worked example)" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &buf));

    // NEXT=5: continue with another clone card.
    var next = try parseCloneSeqno(tokenizeCard("clone_seqno 5"));
    try std.testing.expectEqual(@as(i64, 5), next);
    try std.testing.expectEqual(NextRequest.clone_card, nextRequestKind(true, next));
    try std.testing.expectEqualStrings("clone 3 5", try writeCloneCard(@intCast(next), &buf));

    // NEXT=0: artifact transfer done; a cleanup round, not a clone card.
    next = try parseCloneSeqno(tokenizeCard("clone_seqno 0"));
    try std.testing.expectEqual(@as(i64, 0), next);
    try std.testing.expect(!cloneSeqnoIsFatal(next));
    try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, next));

    // No remaining work: stop.
    try std.testing.expectEqual(NextRequest.stop, nextRequestKind(false, 0));

    // A negative NEXT is the fatal path, never fed to nextRequestKind.
    next = try parseCloneSeqno(tokenizeCard("clone_seqno -1"));
    try std.testing.expectEqual(@as(i64, -1), next);
    try std.testing.expect(cloneSeqnoIsFatal(next));

    try std.testing.expectError(error.Malformed, parseCloneSeqno(tokenizeCard("clone_seqno x")));
}

test "sync §8.3: first clone round suppresses both body-login and cookie-login" {
    try std.testing.expect(!sendableAfterCloneRound0(.clone, 0));
    try std.testing.expect(sendableAfterCloneRound0(.clone, 1));
}

test "sync §8.3: first request is client-version then clone 3 1, no login (worked example)" {
    var clone_buf: [32]u8 = undefined;
    var version_buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &clone_buf));
    const version_line = try writeClientVersion(.{ .version = 20000 }, &version_buf);
    try std.testing.expect(std.mem.startsWith(u8, version_line, "pragma client-version "));
    try std.testing.expect(!sendableAfterCloneRound0(.clone, 0));
}

test "sync §8.3: after clone_seqno 0 phantoms resolve via the ordinary §3.7 path" {
    // Section 8.2's "cleanup round" is, by definition, an ordinary
    // request (L1930-1934): once NEXT hits 0 the CLI runs it as a plain
    // pull round, so continuation is §3.7(a) unchanged, not a new rule.
    try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, 0));
    const pending = RoundObservation{
        .operation = .pull,
        .versioned_content_delivered = true,
        .phantom_table_nonempty = true,
    };
    try std.testing.expect(continueExchange(pending));
    const nothing_pending = RoundObservation{ .operation = .pull };
    try std.testing.expect(!continueExchange(nothing_pending));
}

// ---------------------------------------------------------------------------
// Section 9 — Synchronisation Strategies
// ---------------------------------------------------------------------------

/// §9.1 L2035-2037 — push and pull share one wire shape (`KEYWORD
/// SERVERCODE PROJECTCODE`); one writer keyed on direction avoids
/// duplicating the format string.
pub const PushPull = enum { push, pull };

/// §9.1 L2035-2037 — write `push SERVERCODE PROJECTCODE` or `pull
/// SERVERCODE PROJECTCODE`. Neither field may embed Fossil whitespace —
/// that would silently re-tokenize the card on the wire.
pub fn writePushPullCard(dir: PushPull, servercode: []const u8, projectcode: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(!containsFossilWhitespace(servercode) and !containsFossilWhitespace(projectcode));
    std.debug.assert(servercode.len <= max_line_len and projectcode.len <= max_line_len);
    const keyword = switch (dir) {
        .push => "push",
        .pull => "pull",
    };
    const needed = std.fmt.count("{s} {s} {s}", .{ keyword, servercode, projectcode });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "{s} {s} {s}", .{ keyword, servercode, projectcode });
}

/// §9.1/§8.3 L1999-2002 — a parsed `push|pull SERVERCODE PROJECTCODE`
/// card; PROJECTCODE is also how a clone client, unauthenticated on
/// round 0, first learns the peer's project code (§6.3).
pub const PushPullCard = struct { servercode: []const u8, projectcode: []const u8 };

/// §9.1 L2035-2037 — positional parse; the caller already resolved the
/// operator (push vs. pull) via `classifyCard`/`parseOperator`, so one
/// parser serves both keywords, mirroring `parseReqconfigName`.
pub fn parsePushPull(tokens: Tokens) ?PushPullCard {
    std.debug.assert(tokens.len >= 1);
    const op = parseOperator(tokens.items[0]);
    std.debug.assert(op == .push or op == .pull);
    if (tokens.len < 3) return null;
    return .{ .servercode = tokens.items[1], .projectcode = tokens.items[2] };
}

test "sync §8.3/§9.1: client reads project code from a push or pull card in the reply" {
    try std.testing.expectEqualStrings("PC", parsePushPull(tokenizeCard("push SC PC")).?.projectcode);
    try std.testing.expectEqualStrings("PC", parsePushPull(tokenizeCard("pull SC PC")).?.projectcode);
    try std.testing.expectEqual(@as(?PushPullCard, null), parsePushPull(tokenizeCard("push SC")));
}

/// §9.1 L2039-2041 — the server's project-code check: a mismatch is
/// fatal, never a silent proceed.
pub const SyncProjectCode = enum { proceed, refuse_fatal };

pub fn serverSyncProjectCodeDecision(stored: []const u8, received: []const u8) SyncProjectCode {
    std.debug.assert(stored.len <= max_line_len and received.len <= max_line_len);
    return if (std.mem.eql(u8, stored, received)) .proceed else .refuse_fatal;
}

test "sync §9.1: push/pull/sync carry SERVERCODE PROJECTCODE; mismatch is fatal (rejection)" {
    var push_buf: [64]u8 = undefined;
    var pull_buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("push SC PC", try writePushPullCard(.push, "SC", "PC", &push_buf));
    try std.testing.expectEqualStrings("pull SC PC", try writePushPullCard(.pull, "SC", "PC", &pull_buf));
    // A combined sync carries both cards, each written by the one shared writer.
    try std.testing.expect(std.mem.startsWith(u8, push_buf[0..10], "push "));
    try std.testing.expect(std.mem.startsWith(u8, pull_buf[0..10], "pull "));

    try std.testing.expectEqual(SyncProjectCode.proceed, serverSyncProjectCodeDecision("PC", "PC"));
    try std.testing.expectEqual(SyncProjectCode.refuse_fatal, serverSyncProjectCodeDecision("PC", "XX"));
    try std.testing.expect(causeIsFatal(.exchange, .project_code_mismatch));
}

/// §9.2 L2199-2202, §10.1 L2308-2312 (DATE is YYYYMMDD) — the private-
/// igot suppression threshold as an integer.
pub const private_igot_min_peer_date: u32 = 20200413;

/// §9.2 L2199-2202 — whether a private artifact's igot announcement is
/// suppressed for the recorded peer. `null` (no recorded date) does not
/// satisfy "recorded date is before 2020-04-13" -- the condition needs a
/// date to test, so an unrecorded peer announces normally.
pub const PrivateIgotAction = enum { omit, announce_private };

pub fn privateIgotAction(peer_date: ?u32) PrivateIgotAction {
    comptime std.debug.assert(private_igot_min_peer_date == 20200413);
    const date = peer_date orelse return .announce_private;
    return if (date < private_igot_min_peer_date) .omit else .announce_private;
}

test "sync §9.2: private igot suppressed for peers dated before 2020-04-13 (rejection)" {
    try std.testing.expectEqual(@as(u32, 20200413), private_igot_min_peer_date);
    const Case = struct { date: ?u32, want: PrivateIgotAction };
    const cases = [_]Case{
        .{ .date = 20200412, .want = .omit },
        .{ .date = 20200413, .want = .announce_private }, // strict "before"
        .{ .date = 20200414, .want = .announce_private },
        .{ .date = 19991231, .want = .omit },
        .{ .date = null, .want = .announce_private },
    };
    for (cases) |c| try std.testing.expectEqual(c.want, privateIgotAction(c.date));
}

test "sync §8.2/§9.1 fuzz: parseCloneSeqno/parsePushPull never crash on adversarial tokens" {
    // Untrusted wire tokens: both parsers return only a declared
    // error/optional, never crash, and never accept below their
    // documented floor (clone_seqno needs 2 tokens, push/pull needs 3).
    var prng = std.Random.DefaultPrng.init(0x9A5E_C10E);
    const rand = prng.random();
    const max_field: usize = 24;
    var field_buf: [max_tokens][max_field]u8 = undefined;
    var round: usize = 0;
    while (round < 500) : (round += 1) {
        var tokens: Tokens = .{};
        const n = 1 + rand.uintLessThan(usize, max_tokens);
        tokens.len = n;
        var i: usize = 1;
        while (i < n) : (i += 1) {
            const len = rand.uintLessThan(usize, max_field + 1);
            for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
            tokens.items[i] = field_buf[i][0..len];
        }

        tokens.items[0] = "clone_seqno";
        if (parseCloneSeqno(tokens)) |_| {
            try std.testing.expect(n >= 2);
        } else |_| {}

        tokens.items[0] = "push";
        if (parsePushPull(tokens)) |card| {
            try std.testing.expect(n >= 3);
            try std.testing.expect(card.servercode.len <= max_field);
            try std.testing.expect(card.projectcode.len <= max_field);
        }
    }

    // write -> parse round-trips for well-formed servercode/projectcode.
    var out_buf: [80]u8 = undefined;
    var sc_buf: [16]u8 = undefined;
    var pc_buf: [16]u8 = undefined;
    round = 0;
    while (round < 300) : (round += 1) {
        const sc_len = 1 + rand.uintLessThan(usize, sc_buf.len);
        const pc_len = 1 + rand.uintLessThan(usize, pc_buf.len);
        for (sc_buf[0..sc_len]) |*b| {
            const raw = rand.int(u8);
            // LF terminates a card line and can never survive inside a
            // token (§5.2); Fossil-whitespace bytes would re-split it.
            b.* = if (isFossilWhitespace(raw) or raw == '\n') 'x' else raw;
        }
        for (pc_buf[0..pc_len]) |*b| {
            const raw = rand.int(u8);
            b.* = if (isFossilWhitespace(raw) or raw == '\n') 'x' else raw;
        }
        const dir: PushPull = if (rand.boolean()) .push else .pull;
        const wire = try writePushPullCard(dir, sc_buf[0..sc_len], pc_buf[0..pc_len], &out_buf);
        const parsed = parsePushPull(tokenizeCard(wire)).?;
        try std.testing.expectEqualStrings(sc_buf[0..sc_len], parsed.servercode);
        try std.testing.expectEqualStrings(pc_buf[0..pc_len], parsed.projectcode);
    }
}

// ---------------------------------------------------------------------------
// Section 4 — Transport
// ---------------------------------------------------------------------------

/// §4.1 L1113-1187 — the three protocol-defined Content-Type values.
pub const MediaType = enum { x_fossil, x_fossil_debug, x_fossil_uncompressed };

/// §4.1 per-media-type facts, one row per Content-Type. Callers read a
/// field directly rather than going through a per-fact accessor.
pub const MediaTypeProps = struct {
    /// The literal Content-Type wire string.
    wire: []const u8,
    /// r15 true; r16/r17 false — is the outer message body zlib-wrapped?
    outer_compressed: bool,
    /// §4 r2/r3 — may a request arrive with this Content-Type?
    valid_request_type: bool,
    /// r17 — used only for a clone-v3 reply, never a request.
    clone_reply_only: bool,
    /// r16 — x-fossil-debug is diagnostic-only, not for production sync.
    production_allowed: bool,
};

pub const media_props = std.EnumArray(MediaType, MediaTypeProps).init(.{
    .x_fossil = .{ .wire = "application/x-fossil", .outer_compressed = true, .valid_request_type = true, .clone_reply_only = false, .production_allowed = true },
    .x_fossil_debug = .{ .wire = "application/x-fossil-debug", .outer_compressed = false, .valid_request_type = true, .clone_reply_only = false, .production_allowed = false },
    .x_fossil_uncompressed = .{ .wire = "application/x-fossil-uncompressed", .outer_compressed = false, .valid_request_type = false, .clone_reply_only = true, .production_allowed = true },
});

/// §4 r2/r3 — resolve a Content-Type string against the table; null when
/// it names none of the three protocol media types.
pub fn parseMediaType(s: []const u8) ?MediaType {
    for (std.meta.tags(MediaType)) |v| {
        if (std.mem.eql(u8, s, media_props.get(v).wire)) return v;
    }
    return null;
}

/// §4 — the four ways a sync message reaches the wire; only http/https
/// carry redirects (r13).
pub const Transport = enum { http, https, ssh, file };

/// §4 L1110-1113 (r1) — the request-target is the repository URL's path,
/// unchanged; an empty path uses "/". Total and allocation-free: the
/// result is always a view into `repo_path` or the "/" literal, so the
/// client structurally cannot append a path component (never /xfer).
pub fn syncRequestTarget(repo_path: []const u8) []const u8 {
    return if (repo_path.len == 0) "/" else repo_path;
}

// §4 L1139-1141 (r6, DEFERRED): the request Content-Length is the exact
// byte count of the payload that follows the header block — a bytes-not-
// cards HTTP-header fact the CLI request builder computes when it emits
// the request; there is no computation to perform or test at this layer.

/// §4 L1128-1133 (r4) — the sole protocol-defined nonstandard header.
pub const ssh_transport_header: []const u8 = "X-Fossil-Transport: SSH";

/// §4 L1128-1133 (r4) — sent only when the transport is SSH.
pub fn needsSshTransportHeader(t: Transport) bool {
    return switch (t) {
        .ssh => true,
        .http, .https, .file => false,
    };
}

/// §4 L1135-1139 (r5) outcome: a POST is processed as sync cards; any
/// other method is never processed and is redirected home instead.
pub const MethodDisposition = enum { process, redirect_home };

/// §4 L1135-1139 (r5) — a server MUST NOT process sync cards from a
/// non-POST request; it redirects such a request to the repository home
/// page instead.
pub fn serverMethodDisposition(is_post: bool) MethodDisposition {
    return if (is_post) .process else .redirect_home;
}

/// §4 L1143-1149 (r7/r8) — how the reply body is framed: an exact byte
/// count from Content-Length, or read-until-close when the reply carries
/// none.
pub const ReplyFraming = union(enum) { exact: usize, until_close };

/// §4 L1143-1149 (r7/r8).
pub fn replyFraming(content_length: ?usize) ReplyFraming {
    return if (content_length) |n| .{ .exact = n } else .until_close;
}

/// §4 L1146-1149 (r8) — the hard cap, in bytes, on a Content-Length-less
/// reply body (512 MiB).
pub const reply_body_cap: usize = 536_870_912;

/// §4 L1148-1149 (r9) — a reply that would exceed `reply_body_cap` MUST
/// be rejected rather than buffered without bound.
pub fn replyCapCheck(len: usize) error{ReplyTooLarge}!void {
    if (len > reply_body_cap) return error.ReplyTooLarge;
}

/// §4 L1151-1153 (r10) — at most this many redirects are followed for a
/// single exchange.
pub const max_redirects: usize = 19;

/// §4 L1151-1154 (r10/r11) — `followed` is the count already consumed by
/// this exchange; the twentieth attempt (`followed == max_redirects`)
/// aborts with a fatal redirect-limit error rather than being followed.
pub fn redirectAllowed(followed: usize) error{RedirectLimit}!void {
    if (followed >= max_redirects) return error.RedirectLimit;
}

/// §4 L1154-1156 (r12) — a redirect MUST NOT downgrade an HTTPS exchange
/// to plaintext HTTP.
pub fn checkRedirectDowngrade(from: Transport, to: Transport) error{InsecureRedirect}!void {
    if (from == .https and to == .http) return error.InsecureRedirect;
}

/// §4 L1156-1157 (r13) — SSH and local-file transports never follow a
/// redirect; there is no HTTP exchange for a redirect to occur within.
pub fn redirectAllowedForTransport(t: Transport) bool {
    return switch (t) {
        .http, .https => true,
        .ssh, .file => false,
    };
}

/// Index just past the last '/' within `s[floor..ceil]`, or `floor` when
/// none exists — the start of the trailing path segment ending at
/// `ceil`. Bounded by `ceil - floor` (the scan cannot pass `floor`).
fn lastSegmentStart(s: []const u8, floor: usize, ceil: usize) usize {
    std.debug.assert(floor <= ceil and ceil <= s.len);
    var i = ceil;
    while (i > floor and s[i - 1] != '/') i -= 1;
    std.debug.assert(i >= floor and i <= ceil);
    return i;
}

/// §4 L1157-1159 + operator resolution 2026-07-10 (`ambiguities.md`,
/// SUFFIX-MATCH reading) — strip the longest common trailing path-
/// segment run the redirect Location shares with the original repository
/// path, staying within the Location's path. `orig=/repo/proj`,
/// `location=https://b.example/mirror/proj` strips to
/// `https://b.example/mirror`. Total: a Location with no scheme
/// separator or no path component has nothing strippable and is
/// returned unchanged.
pub fn stripRedirectRepoPath(location: []const u8, orig_repo_path: []const u8) []const u8 {
    const scheme_sep = "://";
    const auth_start = (std.mem.indexOf(u8, location, scheme_sep) orelse return location) + scheme_sep.len;
    const path_start = std.mem.indexOfScalarPos(u8, location, auth_start, '/') orelse return location;

    var loc_end: usize = location.len;
    var orig_end: usize = orig_repo_path.len;
    var cut: usize = location.len;

    // Bounded by orig_repo_path.len: each matched segment strictly
    // shortens orig_end by at least one byte, so this loop can run at
    // most that many times before orig_end reaches zero.
    var guard: usize = 0;
    while (guard <= orig_repo_path.len) : (guard += 1) {
        if (loc_end <= path_start or orig_end == 0) break;
        const loc_seg_start = lastSegmentStart(location, path_start, loc_end);
        const orig_seg_start = lastSegmentStart(orig_repo_path, 0, orig_end);
        const loc_seg = location[loc_seg_start..loc_end];
        const orig_seg = orig_repo_path[orig_seg_start..orig_end];
        if (loc_seg.len == 0 or orig_seg.len == 0 or !std.mem.eql(u8, loc_seg, orig_seg)) break;

        loc_end = if (loc_seg_start > path_start) loc_seg_start - 1 else path_start;
        orig_end = if (orig_seg_start > 0) orig_seg_start - 1 else 0;
        cut = loc_end;
    }

    std.debug.assert(cut <= location.len);
    // cut never regresses past the authority: either no segment matched
    // (cut == location.len) or every cut point was clamped to path_start.
    std.debug.assert(cut >= path_start);
    return location[0..cut];
}

// ---------------------------------------------------------------------------
// Section 4.1 — Message Compression: the zlib container
// ---------------------------------------------------------------------------

/// §4.1 L1199-1208 — r19 uses the zlib default level; r20 (inline-login
/// prepend) uses level 9. r21: the decompressor never depends on which.
pub const CompressLevel = enum { default, best };

fn compressOptions(level: CompressLevel) flate.Compress.Options {
    return switch (level) {
        .default => .level_6,
        .best => .level_9,
    };
}

/// `Compress.init` asserts its output writer already has more than 8
/// bytes of buffer capacity; `Writer.Allocating` starts empty, so the
/// encoder pre-sizes before calling it. 64 is a cited headroom over that
/// `>8` floor, comfortably covering the 4-byte length prefix plus the
/// 2-byte zlib header this container always writes first.
const compress_out_min: usize = 64;

pub const ContainerEncodeError = error{ OutOfMemory, InputTooLarge };

/// §4.1 L1192-1197 (r18) — the compressed container: a 4-byte big-endian
/// uncompressed length, then a standard zlib [RFC1950] stream (2-byte
/// header, DEFLATE body, 4-byte Adler-32 trailer). Not raw DEFLATE, not
/// gzip. Shared with the §7.2 cfile container (deferred here — no
/// cross-layer caller exists yet, so it stays sync-layer-local).
pub fn zlibContainerEncode(gpa: std.mem.Allocator, input: []const u8, level: CompressLevel) ContainerEncodeError![]u8 {
    if (input.len > std.math.maxInt(u32)) return error.InputTooLarge;

    var aw = std.Io.Writer.Allocating.init(gpa);
    defer aw.deinit();
    aw.ensureUnusedCapacity(compress_out_min) catch return error.OutOfMemory;
    aw.writer.writeInt(u32, @intCast(input.len), .big) catch return error.OutOfMemory;

    const window = gpa.alloc(u8, flate.max_window_len) catch return error.OutOfMemory;
    defer gpa.free(window);

    var c = flate.Compress.init(&aw.writer, window, .zlib, compressOptions(level)) catch return error.OutOfMemory;
    c.writer.writeAll(input) catch return error.OutOfMemory;
    c.finish() catch return error.OutOfMemory;

    const out = try aw.toOwnedSlice();
    std.debug.assert(std.mem.readInt(u32, out[0..4], .big) == input.len);
    return out;
}

pub const ContainerDecodeError = error{ OutOfMemory, TruncatedHeader, LengthMismatch, InvalidStream, OutputTooLarge };

/// §4.1 L1192-1197 (r18), r9 — decode a `zlibContainerEncode` container.
/// `max_output` bounds the declared length (rejecting a lying header
/// before any decompression work happens) and the actual stream is
/// pumped no further than `declared + 1` bytes, so a truncated-length
/// lie cannot drive unbounded allocation; the `+1` is what detects a
/// stream that overruns its own declared length. Callers pass
/// `reply_body_cap` as `max_output` to tie this to r8/r9.
pub fn zlibContainerDecode(gpa: std.mem.Allocator, bytes: []const u8, max_output: usize) ContainerDecodeError![]u8 {
    if (bytes.len < 4) return error.TruncatedHeader;
    const declared = std.mem.readInt(u32, bytes[0..4], .big);
    if (declared > max_output) return error.OutputTooLarge;

    var fr = std.Io.Reader.fixed(bytes[4..]);
    const window = gpa.alloc(u8, flate.max_window_len) catch return error.OutOfMemory;
    defer gpa.free(window);
    var dc = flate.Decompress.init(&fr, .zlib, window);

    var aw = std.Io.Writer.Allocating.init(gpa);
    defer aw.deinit();

    const cap: usize = @as(usize, declared) + 1;
    var total: usize = 0;
    // `stream()` may legitimately return 0 progress on a call that only
    // does internal bookkeeping (e.g. parsing a block header) without
    // yet emitting output bytes, so 0 is not itself a completion signal
    // — only `error.EndOfStream` or reaching `cap` ends the pump. The
    // iteration count is still explicitly bounded (input size plus the
    // output cap plus headroom) so a stdlib regression that stalled
    // forever would surface as `InvalidStream` rather than hang.
    const max_iters: usize = bytes.len + cap + 64;
    var iters: usize = 0;
    while (total < cap and iters < max_iters) : (iters += 1) {
        const n = dc.reader.stream(&aw.writer, .limited(cap - total)) catch |err| switch (err) {
            error.EndOfStream => break,
            error.WriteFailed => return error.OutOfMemory,
            error.ReadFailed => return error.InvalidStream,
        };
        total += n;
    }
    if (iters >= max_iters) return error.InvalidStream;

    if (total != declared) return error.LengthMismatch;
    const out = try aw.toOwnedSlice();
    std.debug.assert(out.len == declared);
    return out;
}

// ---------------------------------------------------------------------------
// Section 4.2 — Round Structure
// ---------------------------------------------------------------------------

/// §4.2 L1212-1216 (r22/r23) — the outcome of probing one more offset
/// into a reply body: another card follows, the body is exhausted (a
/// clean round end, not an error), or a partial trailing line remains.
pub const ReplyBodyStep = enum { card_ready, round_complete, truncated };

/// §4.2 L1212-1216 — a round is one POST/response pair ending at body
/// exhaustion (r22); exhaustion at a card boundary is not an error
/// (r23), since there is no terminal card in the protocol.
pub fn replyBodyStep(body: []const u8, offset: usize) ReplyBodyStep {
    std.debug.assert(offset <= body.len);
    if (offset == body.len) return .round_complete;
    _ = readLine(body, offset) catch return .truncated;
    return .card_ready;
}

test "sync §4 r1: request-target is repo path unchanged; empty uses /, never /xfer" {
    const cases = [_]struct { in: []const u8, want: []const u8 }{
        .{ .in = "", .want = "/" },
        .{ .in = "/", .want = "/" },
        .{ .in = "/repo", .want = "/repo" },
        .{ .in = "/cgi-bin/fossil/x", .want = "/cgi-bin/fossil/x" },
    };
    for (cases) |c| {
        const got = syncRequestTarget(c.in);
        try std.testing.expectEqualStrings(c.want, got);
        try std.testing.expect(!std.mem.endsWith(u8, got, "/xfer"));
    }
}

test "sync §4 r2: x-fossil and -debug are valid inbound request Content-Types" {
    try std.testing.expect(media_props.get(.x_fossil).valid_request_type);
    try std.testing.expect(media_props.get(.x_fossil_debug).valid_request_type);
}

test "sync §4 r3: x-fossil-uncompressed is not a request media type" {
    try std.testing.expect(!media_props.get(.x_fossil_uncompressed).valid_request_type);
}

test "sync §4 r4: SSH transport adds X-Fossil-Transport: SSH, the only nonstandard header" {
    try std.testing.expect(needsSshTransportHeader(.ssh));
    try std.testing.expect(!needsSshTransportHeader(.http));
    try std.testing.expect(!needsSshTransportHeader(.https));
    try std.testing.expect(!needsSshTransportHeader(.file));
    try std.testing.expectEqualStrings("X-Fossil-Transport: SSH", ssh_transport_header);
}

test "sync §4 r5: a non-POST sync request is redirected home, cards not processed" {
    try std.testing.expectEqual(MethodDisposition.redirect_home, serverMethodDisposition(false));
    try std.testing.expectEqual(MethodDisposition.process, serverMethodDisposition(true));
}

test "sync §4 r7: a Content-Length reply is read exactly that many bytes" {
    try std.testing.expectEqual(ReplyFraming{ .exact = 300 }, replyFraming(300));
}

test "sync §4 r8: a reply with no Content-Length is read until close, cap 512 MiB" {
    try std.testing.expectEqual(ReplyFraming.until_close, replyFraming(null));
    try std.testing.expectEqual(@as(usize, 536_870_912), reply_body_cap);
}

test "sync §4 r9: a reply exceeding the 512 MiB cap is rejected" {
    try replyCapCheck(reply_body_cap);
    try std.testing.expectError(error.ReplyTooLarge, replyCapCheck(reply_body_cap + 1));
}

test "sync §4 r10: at most 19 HTTP redirects are followed for one exchange" {
    try std.testing.expectEqual(@as(usize, 19), max_redirects);
    try redirectAllowed(18);
}

test "sync §4 r11: the twentieth redirect aborts with a fatal redirect-limit error" {
    try std.testing.expectError(error.RedirectLimit, redirectAllowed(19));
    try std.testing.expect(causeIsFatal(.exchange, .redirect_limit));
}

test "sync §4 r12: a redirect must not downgrade HTTPS to plaintext HTTP" {
    try std.testing.expectError(error.InsecureRedirect, checkRedirectDowngrade(.https, .http));
    try checkRedirectDowngrade(.https, .https);
    try checkRedirectDowngrade(.http, .http);
}

test "sync §4 r13: SSH or local-file transports never follow a redirect" {
    try std.testing.expect(!redirectAllowedForTransport(.ssh));
    try std.testing.expect(!redirectAllowedForTransport(.file));
    try std.testing.expect(redirectAllowedForTransport(.http));
    try std.testing.expect(redirectAllowedForTransport(.https));
}

test "sync §4 r14: a redirect strips the trailing repository-path segment (suffix-match)" {
    // Operator worked example (sole oracle, ambiguities.md 2026-07-10).
    try std.testing.expectEqualStrings(
        "https://b.example/mirror",
        stripRedirectRepoPath("https://b.example/mirror/proj", "/repo/proj"),
    );
    // RFC-derivable negative: no shared trailing segment, nothing stripped.
    try std.testing.expectEqualStrings(
        "https://b.example/other",
        stripRedirectRepoPath("https://b.example/other", "/repo/proj"),
    );
    // No path component on the Location: unchanged.
    try std.testing.expectEqualStrings(
        "https://b.example",
        stripRedirectRepoPath("https://b.example", "/repo/proj"),
    );
}

test "sync §4.1 r15: x-fossil is the compressed all-requests/pull-push-sync-reply type" {
    const p = media_props.get(.x_fossil);
    try std.testing.expectEqualStrings("application/x-fossil", p.wire);
    try std.testing.expect(p.outer_compressed);
    try std.testing.expect(p.valid_request_type);
}

test "sync §4.1 r16: x-fossil-debug is uncompressed and not for production" {
    const p = media_props.get(.x_fossil_debug);
    try std.testing.expectEqualStrings("application/x-fossil-debug", p.wire);
    try std.testing.expect(!p.outer_compressed);
    try std.testing.expect(!p.production_allowed);
}

test "sync §4.1 r17: x-fossil-uncompressed is clone-reply-only, not outer-zlib" {
    const p = media_props.get(.x_fossil_uncompressed);
    try std.testing.expectEqualStrings("application/x-fossil-uncompressed", p.wire);
    try std.testing.expect(p.clone_reply_only);
    try std.testing.expect(!p.outer_compressed);
    try std.testing.expect(!p.valid_request_type);
}

test "sync §4.1: parseMediaType inverts the media_props table" {
    for (std.meta.tags(MediaType)) |v| {
        try std.testing.expectEqual(v, parseMediaType(media_props.get(v).wire).?);
    }
    try std.testing.expect(parseMediaType("text/plain") == null);
}

test "sync §4.1 r18: the container is BE32 length then a zlib stream (worked example)" {
    const encoded = try zlibContainerEncode(std.testing.allocator, "abc", .default);
    defer std.testing.allocator.free(encoded);

    // BE32 uncompressed length (r18).
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0, 0, 3 }, encoded[0..4]);
    // RFC1950 §2.2 MUST: CM (low nibble of CMF) is 8 (deflate).
    try std.testing.expectEqual(@as(u8, 8), encoded[4] & 0x0f);
    // RFC1950 §2.2 MUST: (CMF*256 + FLG) % 31 == 0 (FCHECK).
    const cmf_flg = @as(u16, encoded[4]) * 256 + @as(u16, encoded[5]);
    try std.testing.expectEqual(@as(u16, 0), cmf_flg % 31);
    // FDICT must be 0 — no preset dictionary.
    try std.testing.expectEqual(@as(u8, 0), encoded[5] & 0x20);
    // RFC1950 §9 Adler-32 of "abc": s1=1+97+98+99=295=0x0127,
    // s2=0+98+196+295=589=0x024D, stored MSB-first as the trailer.
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0x02, 0x4D, 0x01, 0x27 }, encoded[encoded.len - 4 ..]);
}

test "sync §4.1 r19: the ordinary single-input path uses the zlib default level" {
    const encoded = try zlibContainerEncode(std.testing.allocator, "hello fossil sync", .default);
    defer std.testing.allocator.free(encoded);
    const decoded = try zlibContainerDecode(std.testing.allocator, encoded, reply_body_cap);
    defer std.testing.allocator.free(decoded);
    try std.testing.expectEqualStrings("hello fossil sync", decoded);
}

test "sync §4.1 r20: an inline-login prepend compresses the concatenation at level 9" {
    const login = "login foo bar baz\n";
    const body = "gimme 0123456789012345678901234567890123456789\n";
    const combined = login ++ body;

    const encoded = try zlibContainerEncode(std.testing.allocator, combined, .best);
    defer std.testing.allocator.free(encoded);
    try std.testing.expectEqual(@as(u32, combined.len), std.mem.readInt(u32, encoded[0..4], .big));

    const decoded = try zlibContainerDecode(std.testing.allocator, encoded, reply_body_cap);
    defer std.testing.allocator.free(decoded);
    try std.testing.expectEqualStrings(combined, decoded);
}

test "sync §4.1 r21: decoding does not depend on the compression level" {
    const input = "the quick brown fox jumps over the lazy dog";

    const enc_default = try zlibContainerEncode(std.testing.allocator, input, .default);
    defer std.testing.allocator.free(enc_default);
    const enc_best = try zlibContainerEncode(std.testing.allocator, input, .best);
    defer std.testing.allocator.free(enc_best);

    const dec_default = try zlibContainerDecode(std.testing.allocator, enc_default, reply_body_cap);
    defer std.testing.allocator.free(dec_default);
    const dec_best = try zlibContainerDecode(std.testing.allocator, enc_best, reply_body_cap);
    defer std.testing.allocator.free(dec_best);

    try std.testing.expectEqualStrings(input, dec_default);
    try std.testing.expectEqualStrings(input, dec_best);
}

test "sync §4.1: a truncated container header is rejected" {
    try std.testing.expectError(error.TruncatedHeader, zlibContainerDecode(std.testing.allocator, "\x00\x00", reply_body_cap));
}

test "sync §4.1: a length-field/stream mismatch is rejected" {
    const encoded = try zlibContainerEncode(std.testing.allocator, "mismatch me", .default);
    defer std.testing.allocator.free(encoded);
    var lied: [64]u8 = undefined;
    @memcpy(lied[0..encoded.len], encoded);
    std.mem.writeInt(u32, lied[0..4], 99, .big); // declares 99, stream carries fewer
    try std.testing.expectError(error.LengthMismatch, zlibContainerDecode(std.testing.allocator, lied[0..encoded.len], reply_body_cap));
}

test "sync §4.1: a declared length over max_output is rejected" {
    const encoded = try zlibContainerEncode(std.testing.allocator, "capped", .default);
    defer std.testing.allocator.free(encoded);
    try std.testing.expectError(error.OutputTooLarge, zlibContainerDecode(std.testing.allocator, encoded, 2));
}

test "sync §4.1: garbage after the length prefix is rejected" {
    const garbage = [_]u8{ 0, 0, 0, 5, 0xff, 0xff, 0xff, 0xff, 0xff };
    try std.testing.expectError(error.InvalidStream, zlibContainerDecode(std.testing.allocator, &garbage, reply_body_cap));
}

test "sync §4.1 fuzz: container round-trip and garbage-decode never crash" {
    // Seeded deterministic fuzz over untrusted wire input: a random
    // payload survives encode->decode at either level unchanged, and
    // arbitrary garbage into the decoder always resolves to a declared
    // error, never a panic.
    var prng = std.Random.DefaultPrng.init(0xC0FF_EE00);
    const rand = prng.random();
    var round: usize = 0;
    const max_len: usize = 96;
    var buf: [max_len]u8 = undefined;
    var garbage_buf: [max_len]u8 = undefined;
    while (round < 300) : (round += 1) {
        const len = rand.uintLessThan(usize, max_len + 1);
        for (buf[0..len]) |*b| b.* = rand.int(u8);
        const level: CompressLevel = if (rand.boolean()) .default else .best;

        const encoded = try zlibContainerEncode(std.testing.allocator, buf[0..len], level);
        defer std.testing.allocator.free(encoded);
        const decoded = try zlibContainerDecode(std.testing.allocator, encoded, reply_body_cap);
        defer std.testing.allocator.free(decoded);
        try std.testing.expectEqualSlices(u8, buf[0..len], decoded);

        const glen = rand.uintLessThan(usize, max_len + 1);
        for (garbage_buf[0..glen]) |*b| b.* = rand.int(u8);
        if (zlibContainerDecode(std.testing.allocator, garbage_buf[0..glen], reply_body_cap)) |ok| {
            std.testing.allocator.free(ok);
        } else |_| {}
    }
}

fn encodeNoLeak(gpa: std.mem.Allocator) !void {
    const out = try zlibContainerEncode(gpa, "allocation failure probe", .default);
    gpa.free(out);
}

test "sync §4.1: encode survives allocation failure" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, encodeNoLeak, .{});
}

fn decodeNoLeak(gpa: std.mem.Allocator) !void {
    const encoded = try zlibContainerEncode(gpa, "allocation failure probe", .default);
    defer gpa.free(encoded);
    const out = try zlibContainerDecode(gpa, encoded, reply_body_cap);
    gpa.free(out);
}

test "sync §4.1: decode survives allocation failure" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, decodeNoLeak, .{});
}

test "sync §4.2 r22: a round is one POST+response ending at body exhaustion" {
    const body = "cookie x\ngimme " ++ ("a" ** 40) ++ "\n";
    try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, body.len));
    try std.testing.expectEqual(ReplyBodyStep.card_ready, replyBodyStep(body, 0));
}

test "sync §4.2 r23: body exhaustion at a card boundary is not an error" {
    const body = "cookie x\n";
    try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, body.len));
    const partial = "cookie x";
    try std.testing.expectEqual(ReplyBodyStep.truncated, replyBodyStep(partial, 0));
}

test "sync §4.3: the cookie card is one opaque TEXT, in a request or a response" {
    var buf: [24]u8 = undefined;
    const written = writeCookieCard("s3cr3t", &buf);
    try std.testing.expectEqualStrings("cookie s3cr3t", written);

    const c = classifyCard(written);
    try std.testing.expectEqual(Disposition.dispatch, c.disposition);

    var store: CookieStore = .{};
    var decode_buf: [16]u8 = undefined;
    const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?;
    store.store(decoded);
    try std.testing.expectEqualStrings("s3cr3t", store.value().?);

    // Same shape either direction: the server's emit and the client's
    // return are both exactly this card.
    var buf2: [24]u8 = undefined;
    try std.testing.expectEqualStrings(written, writeCookieCard(store.value().?, &buf2));
}

// ---------------------------------------------------------------------------
// Section 6 — Authentication
// ---------------------------------------------------------------------------

/// §6 — 40 lowercase-hex ASCII bytes; every digest this section produces.
pub const HashHex = [40]u8;

/// §6 — the one digest->hex conversion site every §6 formula funnels
/// through, so a hex-case or width bug can only ever be fixed in one place.
fn sha1FinalHex(digest: [20]u8) HashHex {
    return std.fmt.bytesToHex(digest, .lower);
}

/// §6 — SHA-1 [RFC3174] of `parts` concatenated with no separator, as 40
/// lowercase hex. The sole hashing funnel §6.2/§6.3/§6.4 share; a part
/// boundary never enters the digest, so callers own their own separators.
fn sha1HexOf(parts: []const []const u8) HashHex {
    var hasher = std.crypto.hash.Sha1.init(.{});
    for (parts) |p| hasher.update(p);
    var digest: [20]u8 = undefined;
    hasher.final(&digest);
    return sha1FinalHex(digest);
}

/// §6.2 — NONCE = SHA1(body bytes after the login-card LF). `body_tail`
/// inherits the §4 r8 reply-body bound since it is itself a message body.
pub fn computeNonce(body_tail: []const u8) HashHex {
    std.debug.assert(body_tail.len <= reply_body_cap);
    return sha1HexOf(&.{body_tail});
}

/// §6.3 — shared-secret = SHA1(project-code "/" login "/" password).
pub fn deriveSharedSecret(project_code: []const u8, login: []const u8, password: []const u8) HashHex {
    std.debug.assert(login.len > 0 and !containsFossilWhitespace(login));
    std.debug.assert(project_code.len <= max_line_len);
    return sha1HexOf(&.{ project_code, "/", login, "/", password });
}

/// §6.4 — SIGNATURE = SHA1(NONCE-hex || secret), an 80-byte input when
/// `secret` is itself 40-hex. `secret` is either the 40-hex shared-secret
/// (client; §6.4 step 3 fallback) or the raw stored credential bytes at
/// whatever width the server happens to store them (§6.4 step 2 primary) —
/// one function serves both r8 and r13.
pub fn computeSignature(nonce_hex: []const u8, secret: []const u8) HashHex {
    std.debug.assert(nonce_hex.len == 40);
    return sha1HexOf(&.{ nonce_hex, secret });
}

test "sync §A.5/§6.2 r5: computeNonce('example-request-body-bytes')" {
    const nonce = computeNonce("example-request-body-bytes");
    try std.testing.expectEqualStrings("5dfa03bd8645938b4e312a4fcdad5ebe6d4d4840", &nonce);
}

test "sync §A.5/§6.3 r7: deriveSharedSecret(project,alice,secret-passphrase)" {
    const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase");
    try std.testing.expectEqualStrings("d7a235115bf97d126c42140196c4fc6087312353", &secret);
}

test "sync §A.5/§6.4 r8: computeSignature(nonce,secret)" {
    const nonce = computeNonce("example-request-body-bytes");
    const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase");
    const signature = computeSignature(&nonce, &secret);
    try std.testing.expectEqualStrings("e3dba1c99442823b76ae64a8afca940e0327f9f8", &signature);
}

test "sync §6.2 [RFC3174] §7.3: SHA1('abc') primitive vector" {
    const digest = sha1HexOf(&.{"abc"});
    try std.testing.expectEqualStrings("a9993e364706816aba3e25717850c26c9cd0d89d", &digest);
}

test "sync §6.4 r8/r13: computeSignature is 40-hex over an 80-byte input regardless of secret width" {
    const nonce = computeNonce("example-request-body-bytes");
    const secret_hex = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase");
    const sig_from_hex_secret = computeSignature(&nonce, &secret_hex);
    try std.testing.expectEqualStrings("e3dba1c99442823b76ae64a8afca940e0327f9f8", &sig_from_hex_secret);

    const raw_secret = "short-raw-cred"; // arbitrary-width stored credential, primary form
    const sig_from_raw_secret = computeSignature(&nonce, raw_secret);
    try std.testing.expectEqual(@as(usize, 40), sig_from_raw_secret.len);
    try std.testing.expect(!std.mem.eql(u8, &sig_from_hex_secret, &sig_from_raw_secret));
}

test "sync §6.4: distinct body tails yield distinct nonces (replay note)" {
    const n1 = computeNonce("round one body bytes");
    const n2 = computeNonce("round two body bytes");
    try std.testing.expect(!std.mem.eql(u8, &n1, &n2));
}

/// §6.1 — a parsed `login USER NONCE SIGNATURE`. Slices borrow the source
/// (a card line or a decoded cookie value); the caller owns that memory.
pub const LoginCard = struct { user: []const u8, nonce: []const u8, signature: []const u8 };

/// §6.1 — exactly 40 lowercase hex ASCII bytes, the NONCE/SIGNATURE shape.
fn isLowerHex40(s: []const u8) bool {
    if (s.len != 40) return false;
    for (s) |c| {
        switch (c) {
            '0'...'9', 'a'...'f' => {},
            else => return false,
        }
    }
    return true;
}

/// §6.1 — parse a login card's tokens into a `LoginCard`, or null on any
/// malformed shape: wrong token count, an unrecognized first token, an
/// empty or Fossil-whitespace-bearing USER, or a NONCE/SIGNATURE that
/// isn't exactly 40 lowercase hex. Errors are defined out of existence via
/// `?` rather than an error set — every rejection is equally "not a login
/// card". `tokens` may come from `tokenizeCard` (body card) or from a
/// cookie-decoded line (§6.5), so the operator token is checked here
/// rather than assumed already-dispatched.
pub fn parseLoginCard(tokens: Tokens) ?LoginCard {
    std.debug.assert(tokens.len <= max_tokens);
    if (tokens.len != 4) return null;
    if (parseOperator(tokens.items[0]) != .login) return null;
    const user = tokens.items[1];
    if (user.len == 0 or containsFossilWhitespace(user)) return null;
    const nonce = tokens.items[2];
    const signature = tokens.items[3];
    if (!isLowerHex40(nonce)) return null;
    if (!isLowerHex40(signature)) return null;
    return .{ .user = user, .nonce = nonce, .signature = signature };
}

test "sync §6.1: login USER NONCE SIGNATURE parses (worked card, §A.3 L3236)" {
    const line = "login privuser ee71329ef84fb77b428c6f626ad0103a426e0652 c3ee0be781c9ebcfc6e24756cbb75e6319ca255b";
    const card = parseLoginCard(tokenizeCard(line)).?;
    try std.testing.expectEqualStrings("privuser", card.user);
    try std.testing.expectEqualStrings("ee71329ef84fb77b428c6f626ad0103a426e0652", card.nonce);
    try std.testing.expectEqualStrings("c3ee0be781c9ebcfc6e24756cbb75e6319ca255b", card.signature);
}

test "sync §6.1: login card rejected — wrong token count" {
    try std.testing.expect(parseLoginCard(tokenizeCard("login onlyuser")) == null);
    try std.testing.expect(parseLoginCard(tokenizeCard("login a b c d")) == null);
}

test "sync §6.1: login card rejected — NONCE not 40 lowercase hex" {
    const sig = "c3ee0be781c9ebcfc6e24756cbb75e6319ca255b";
    const cases = [_][]const u8{
        "login u ee71329ef84fb77b428c6f626ad0103a426e065 " ++ sig, // 39 chars, too short
        "login u ee71329ef84fb77b428c6f626ad0103a426e06520 " ++ sig, // 41 chars, too long
        "login u EE71329EF84FB77B428C6F626AD0103A426E0652 " ++ sig, // uppercase
        "login u ge71329ef84fb77b428c6f626ad0103a426e0652 " ++ sig, // non-hex 'g'
    };
    for (cases) |line| {
        try std.testing.expect(parseLoginCard(tokenizeCard(line)) == null);
    }
}

test "sync §6.1: login card rejected — SIGNATURE not 40 lowercase hex" {
    const nonce = "ee71329ef84fb77b428c6f626ad0103a426e0652";
    const cases = [_][]const u8{
        "login u " ++ nonce ++ " c3ee0be781c9ebcfc6e24756cbb75e6319ca25", // 38 chars, too short
        "login u " ++ nonce ++ " c3ee0be781c9ebcfc6e24756cbb75e6319ca255bff", // 42 chars, too long
        "login u " ++ nonce ++ " C3EE0BE781C9EBCFC6E24756CBB75E6319CA255B", // uppercase
        "login u " ++ nonce ++ " z3ee0be781c9ebcfc6e24756cbb75e6319ca255b", // non-hex 'z'
    };
    for (cases) |line| {
        try std.testing.expect(parseLoginCard(tokenizeCard(line)) == null);
    }
}

test "sync §6.1: login card rejected — empty/whitespace USER" {
    const nonce = "ee71329ef84fb77b428c6f626ad0103a426e0652";
    const sig = "c3ee0be781c9ebcfc6e24756cbb75e6319ca255b";
    // Constructed directly: tokenizeCard itself never yields an empty or
    // whitespace-bearing token, so this exercises the defensive check for
    // a Tokens value assembled from a non-tokenizeCard source.
    var empty_user: Tokens = .{ .len = 4 };
    empty_user.items = .{ "login", "", nonce, sig, undefined, undefined };
    try std.testing.expect(parseLoginCard(empty_user) == null);

    var ws_user: Tokens = .{ .len = 4 };
    ws_user.items = .{ "login", "us er", nonce, sig, undefined, undefined };
    try std.testing.expect(parseLoginCard(ws_user) == null);
}

/// §6.5 — RFC 3986 unreserved bytes (letters, digits, '-', '.', '_', '~')
/// pass through unescaped; every other byte is percent-encoded. §6.5 says
/// only "URL-unsafe bytes ... are percent-encoded" without naming an exact
/// table, so this is the conventional unreserved set — not literally the
/// historical application/x-www-form-urlencoded charset, which omits '~'
/// and adds '*'.
fn isUrlUnreserved(b: u8) bool {
    return switch (b) {
        'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
        else => false,
    };
}

fn hexDigitUpper(n: u4) u8 {
    const v: u8 = n;
    return if (v < 10) '0' + v else 'A' + (v - 10);
}

fn hexNibble(c: u8) ?u4 {
    return switch (c) {
        '0'...'9' => @intCast(c - '0'),
        'a'...'f' => @intCast(c - 'a' + 10),
        'A'...'F' => @intCast(c - 'A' + 10),
        else => null,
    };
}

/// §6.5 — append `s` to `out` starting at `start`, SP -> '+' and every
/// non-unreserved byte -> `%XX`; null on overflow. Bounded by `s.len`.
fn urlFormEncodeAppend(s: []const u8, out: []u8, start: usize) ?usize {
    var o = start;
    for (s) |b| {
        if (b == ' ') {
            if (o >= out.len) return null;
            out[o] = '+';
            o += 1;
        } else if (isUrlUnreserved(b)) {
            if (o >= out.len) return null;
            out[o] = b;
            o += 1;
        } else {
            if (o + 3 > out.len) return null;
            out[o] = '%';
            out[o + 1] = hexDigitUpper(@intCast(b >> 4));
            out[o + 2] = hexDigitUpper(@intCast(b & 0x0f));
            o += 3;
        }
    }
    return o;
}

/// §6.5 — decode URL form-encoded bytes into `out` ('+' -> SP, `%XX` ->
/// byte); null on a truncated or non-hex escape. Decoding reverses
/// whatever safe-byte choice the encoder made, so it needs no charset
/// table of its own.
/// Decoding only ever shrinks, so `out.len >= value.len` always suffices.
fn urlFormDecode(value: []const u8, out: []u8) ?[]u8 {
    std.debug.assert(out.len >= value.len);
    var i: usize = 0;
    var o: usize = 0;
    while (i < value.len) {
        const b = value[i];
        if (b == '+') {
            out[o] = ' ';
            o += 1;
            i += 1;
        } else if (b == '%') {
            if (i + 2 >= value.len) return null;
            const hi = hexNibble(value[i + 1]) orelse return null;
            const lo = hexNibble(value[i + 2]) orelse return null;
            out[o] = (@as(u8, hi) << 4) | @as(u8, lo);
            o += 1;
            i += 3;
        } else {
            out[o] = b;
            o += 1;
            i += 1;
        }
    }
    return out[0..o];
}

/// §6.5 — encode a login card as the `x-f-l-c` cookie value: `login+USER+
/// NONCE+SIGNATURE`, SP -> '+', URL-unsafe USER bytes percent-encoded.
/// NONCE/SIGNATURE are already 40 lowercase hex (unreserved), so only USER
/// ever needs escaping. Null on an `out` overflow.
pub fn writeCookieLogin(card: LoginCard, out: []u8) ?[]u8 {
    std.debug.assert(card.user.len > 0);
    std.debug.assert(card.nonce.len == 40 and card.signature.len == 40);
    const prefix = "login+";
    if (out.len < prefix.len) return null;
    @memcpy(out[0..prefix.len], prefix);
    var o = urlFormEncodeAppend(card.user, out, prefix.len) orelse return null;
    if (o + 1 + card.nonce.len + 1 + card.signature.len > out.len) return null;
    out[o] = '+';
    o += 1;
    @memcpy(out[o..][0..card.nonce.len], card.nonce);
    o += card.nonce.len;
    out[o] = '+';
    o += 1;
    @memcpy(out[o..][0..card.signature.len], card.signature);
    o += card.signature.len;
    return out[0..o];
}

/// §6.5 — decode an `x-f-l-c` cookie value into `out`, then parse it by
/// the same §6.1 path as a body login card; null on any malformed value.
/// `out` is a caller buffer, `out.len >= value.len` required (decode never
/// grows) and a decoded line over `max_line_len - 1` or carrying an
/// embedded LF is rejected explicitly — the value is untrusted wire input,
/// so `tokenizeCard`'s own preconditions must be established here first.
pub fn parseCookieLogin(value: []const u8, out: []u8) ?LoginCard {
    std.debug.assert(out.len >= value.len);
    const decoded = urlFormDecode(value, out) orelse return null;
    if (decoded.len == 0 or decoded.len > max_line_len - 1) return null;
    if (std.mem.indexOfScalar(u8, decoded, '\n') != null) return null;
    return parseLoginCard(tokenizeCard(decoded));
}

test "sync §A.3/§6.5 r?: writeCookieLogin encodes SP->+ (privuser round-2 card, L3268)" {
    const card: LoginCard = .{
        .user = "privuser",
        .nonce = "653828a8fc753c1685501de457c104f0b20938c9",
        .signature = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2",
    };
    var buf: [128]u8 = undefined;
    const cookie = writeCookieLogin(card, &buf).?;
    try std.testing.expectEqualStrings(
        "login+privuser+653828a8fc753c1685501de457c104f0b20938c9+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2",
        cookie,
    );
}

test "sync §6.5: parseCookieLogin round-trips the L3268 x-f-l-c value" {
    const value = "login+privuser+653828a8fc753c1685501de457c104f0b20938c9+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
    var buf: [128]u8 = undefined;
    const card = parseCookieLogin(value, &buf).?;
    try std.testing.expectEqualStrings("privuser", card.user);
    try std.testing.expectEqualStrings("653828a8fc753c1685501de457c104f0b20938c9", card.nonce);
    try std.testing.expectEqualStrings("bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", card.signature);
}

test "sync §6.5: parseCookieLogin rejects a malformed value" {
    var buf: [128]u8 = undefined;
    try std.testing.expect(parseCookieLogin("login+privuser+short+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", &buf) == null);
    try std.testing.expect(parseCookieLogin("login+privuser+%zz+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", &buf) == null);
}

test "sync §6.1/§6.5 fuzz: parseLoginCard never crashes on adversarial tokens" {
    // Untrusted wire tokens: parseLoginCard must never crash, only return
    // null or a card whose fields satisfy §6.1's own shape invariants.
    var prng = std.Random.DefaultPrng.init(0x106E_6CDE);
    const rand = prng.random();
    const max_field: usize = 48;
    var field_buf: [max_tokens][max_field]u8 = undefined;
    var round: usize = 0;
    while (round < 500) : (round += 1) {
        var tokens: Tokens = .{};
        const n = rand.uintLessThan(usize, max_tokens + 1);
        tokens.len = n;
        var i: usize = 0;
        while (i < n) : (i += 1) {
            if (i == 0 and rand.boolean()) {
                tokens.items[0] = "login";
                continue;
            }
            const len = rand.uintLessThan(usize, max_field + 1);
            for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
            tokens.items[i] = field_buf[i][0..len];
        }
        const result = parseLoginCard(tokens);
        if (result) |card| {
            try std.testing.expect(card.user.len > 0 and !containsFossilWhitespace(card.user));
            try std.testing.expect(isLowerHex40(card.nonce));
            try std.testing.expect(isLowerHex40(card.signature));
        }
    }
}

test "sync §6.5 fuzz: parseCookieLogin never crashes; writeCookieLogin round-trips" {
    var prng = std.Random.DefaultPrng.init(0xC00C_1E01);
    const rand = prng.random();
    const max_user: usize = 24;
    var garbage_buf: [96]u8 = undefined;
    var decode_buf: [200]u8 = undefined;
    var user_buf: [max_user]u8 = undefined;
    var cookie_buf: [200]u8 = undefined;
    const hex_digits = "0123456789abcdef";
    var round: usize = 0;
    while (round < 300) : (round += 1) {
        // Arbitrary bytes never crash the decoder.
        const glen = rand.uintLessThan(usize, garbage_buf.len + 1);
        for (garbage_buf[0..glen]) |*b| b.* = rand.int(u8);
        _ = parseCookieLogin(garbage_buf[0..glen], &decode_buf);

        // A valid card round-trips through write -> decode. Fossil-
        // whitespace bytes and LF are excluded from USER: those
        // legitimately fail parsing after decoding (whitespace fails
        // §6.1's USER check, covered separately above; LF can never
        // survive as a single card-line token — it is the terminator),
        // not a round-trip failure of the encoder itself.
        const ulen = 1 + rand.uintLessThan(usize, max_user);
        for (user_buf[0..ulen]) |*b| {
            const raw = rand.int(u8);
            b.* = if (isFossilWhitespace(raw) or raw == '\n') 'x' else raw;
        }
        var nonce_hex: [40]u8 = undefined;
        var sig_hex: [40]u8 = undefined;
        for (&nonce_hex) |*c| c.* = hex_digits[rand.uintLessThan(u8, 16)];
        for (&sig_hex) |*c| c.* = hex_digits[rand.uintLessThan(u8, 16)];
        const card: LoginCard = .{ .user = user_buf[0..ulen], .nonce = &nonce_hex, .signature = &sig_hex };
        const cookie = writeCookieLogin(card, &cookie_buf).?;
        const round_tripped = parseCookieLogin(cookie, &decode_buf).?;
        try std.testing.expectEqualStrings(card.user, round_tripped.user);
        try std.testing.expectEqualStrings(card.nonce, round_tripped.nonce);
        try std.testing.expectEqualStrings(card.signature, round_tripped.signature);
    }
}

/// §6.4 — constant-time, case-sensitive equality of two 40-hex hashes.
/// `b` is wire-derived (a received NONCE/SIGNATURE), so its width is
/// checked explicitly rather than assumed.
fn eqHash(a: HashHex, b: []const u8) bool {
    std.debug.assert(b.len == 40);
    var b_arr: HashHex = undefined;
    @memcpy(&b_arr, b);
    return std.crypto.timing_safe.eql(HashHex, a, b_arr);
}

/// §6.4 outcome. `special_case` is the nobody/anonymous unconditional
/// pass; a matching remote-user (HTTP Basic) identity is server-config IO
/// and stays out of this pure function.
pub const LoginResult = enum { special_case, ok, reject };

/// §6.4 steps 1-4 — verify a login card server-side: nobody/anonymous
/// pass unconditionally; otherwise the primary signature check (stored
/// bytes as secret) must pass, or — only when it fails and the stored
/// credential is not exactly 40 bytes — the stored-credential fallback
/// (re-derive via §6.3) must pass; independently, the tail-hash (fresh
/// NONCE over `body_tail`) must match the received NONCE. Both checks use
/// constant-time comparison. Sans-IO, no allocator.
pub fn serverVerifyLogin(
    card: LoginCard,
    stored_credential: []const u8,
    project_code: []const u8,
    body_tail: []const u8,
) LoginResult {
    std.debug.assert(card.nonce.len == 40 and card.signature.len == 40);
    if (std.mem.eql(u8, card.user, "nobody") or std.mem.eql(u8, card.user, "anonymous")) {
        return .special_case;
    }

    const primary_sig = computeSignature(card.nonce, stored_credential);
    var signature_ok = eqHash(primary_sig, card.signature);
    if (!signature_ok and stored_credential.len != 40) {
        const fallback_secret = deriveSharedSecret(project_code, card.user, stored_credential);
        const fallback_sig = computeSignature(card.nonce, &fallback_secret);
        signature_ok = eqHash(fallback_sig, card.signature);
    }

    const tail_hash = computeNonce(body_tail);
    const tail_ok = eqHash(tail_hash, card.nonce);

    return if (signature_ok and tail_ok) .ok else .reject;
}

test "sync §6.4 step1: nobody/anonymous succeed unconditionally" {
    const dummy = "0" ** 40;
    const nobody: LoginCard = .{ .user = "nobody", .nonce = dummy, .signature = dummy };
    try std.testing.expectEqual(LoginResult.special_case, serverVerifyLogin(nobody, "x", "x", "x"));
    const anon: LoginCard = .{ .user = "anonymous", .nonce = dummy, .signature = dummy };
    try std.testing.expectEqual(LoginResult.special_case, serverVerifyLogin(anon, "x", "x", "x"));
}

test "sync §6.4 step2: primary signature check passes (stored bytes as secret) with valid tail-hash" {
    const body_tail = "some request body tail bytes";
    const nonce = computeNonce(body_tail);
    const stored_secret = "arbitrary-stored-credential-bytes";
    const signature = computeSignature(&nonce, stored_secret);
    const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &signature };
    try std.testing.expectEqual(LoginResult.ok, serverVerifyLogin(card, stored_secret, "unused-project-code", body_tail));
}

test "sync §6.4 step3: stored-credential fallback re-derives only when primary fails and stored len != 40" {
    const body_tail = "another body tail";
    const nonce = computeNonce(body_tail);
    const project_code = "c1b2a3d4e5f60718293a4b5c6d7e8f9012345678";
    const login = "alice";
    const cleartext_password = "secret-passphrase"; // stored credential, len != 40
    const shared_secret = deriveSharedSecret(project_code, login, cleartext_password);
    const signature = computeSignature(&nonce, &shared_secret);
    const card: LoginCard = .{ .user = login, .nonce = &nonce, .signature = &signature };
    // The primary form (cleartext_password treated directly as the raw
    // secret) does not match; only the §6.3 fallback re-derivation does.
    try std.testing.expectEqual(LoginResult.ok, serverVerifyLogin(card, cleartext_password, project_code, body_tail));
}

test "sync §6.4: login rejected — signature mismatch" {
    const body_tail = "reject sig body";
    const nonce = computeNonce(body_tail);
    const stored_secret = "cred-bytes";
    const wrong_signature = "0" ** 40;
    const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = wrong_signature };
    try std.testing.expectEqual(LoginResult.reject, serverVerifyLogin(card, stored_secret, "project", body_tail));
}

test "sync §6.4: login rejected — tail-hash mismatch" {
    const original_tail = "original body tail bytes";
    const tampered_tail = "tampered body tail bytes!!";
    const nonce = computeNonce(original_tail);
    const stored_secret = "cred-bytes";
    const signature = computeSignature(&nonce, stored_secret);
    const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &signature };
    // The server recomputes NONCE from the body tail it actually
    // received, which is the tampered one — signature is valid but the
    // recomputed tail hash no longer matches the carried NONCE.
    try std.testing.expectEqual(LoginResult.reject, serverVerifyLogin(card, stored_secret, "project", tampered_tail));
}

test "sync §6.4: fallback NOT attempted when stored len == 40" {
    const body_tail = "fallback-gate body";
    const nonce = computeNonce(body_tail);
    const project_code = "project-code";
    const login = "alice";
    const password_40 = "x" ** 40; // exactly 40 bytes: a cleartext-shaped credential
    const would_be_fallback_secret = deriveSharedSecret(project_code, login, password_40);
    const signature = computeSignature(&nonce, &would_be_fallback_secret);
    const card: LoginCard = .{ .user = login, .nonce = &nonce, .signature = &signature };
    // If the fallback were (incorrectly) attempted despite stored.len ==
    // 40, this signature would verify. §6.4 step 3 gates the fallback on
    // stored.len != 40, so the primary-only check must reject.
    try std.testing.expectEqual(LoginResult.reject, serverVerifyLogin(card, password_40, project_code, body_tail));
}

test "sync §6.4: eqHash is case-sensitive" {
    const hex_lower = "d7a235115bf97d126c42140196c4fc6087312353";
    var a: HashHex = undefined;
    @memcpy(&a, hex_lower);
    try std.testing.expect(eqHash(a, hex_lower));

    var flipped: [40]u8 = undefined;
    @memcpy(&flipped, hex_lower);
    flipped[0] = 'D'; // flip case of the first hex digit
    try std.testing.expect(!eqHash(a, &flipped));
}

// ---------------------------------------------------------------------------
// Section 7.1 / 7.2 / 7.4 — Content Transfer: file / cfile Cards
// ---------------------------------------------------------------------------

/// §7.1-r1/r5 — a `file` card: HASH, an optional delta basis (four-argument
/// form only), and SIZE, the uncompressed payload byte count.
pub const FileCard = struct {
    hash: []const u8,
    deltasrc: ?[]const u8,
    size: u32,
};

/// §7.2-r1/r2 — a `cfile` card: HASH, an optional delta basis, USIZE (the
/// sender's reconstructed uncompressed size — syntax only, §7.2-r5), and
/// CSIZE, the wire payload byte count that actually frames the payload.
pub const CfileCard = struct {
    hash: []const u8,
    deltasrc: ?[]const u8,
    uncompressed_size: u32,
    compressed_size: u32,
};

/// §7 — the sole `file`/`cfile` parse failure. Unlike §10.3's `config`,
/// SIZE/USIZE/CSIZE have no signed literal to reject as a distinct case;
/// a negative token is simply unparseable as `u32` and folds into this.
pub const CardParseError = error{Malformed};

/// §7.1-r1/r5 — parse `file HASH SIZE` or `file HASH DELTASRC SIZE`.
/// Arity alone disambiguates the two forms; DELTASRC is never inspected
/// beyond being present, so this layer takes no hash-policy dependency.
pub fn parseFileCard(tokens: Tokens) CardParseError!FileCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .file);
    std.debug.assert(tokens.len <= max_tokens);
    const result: FileCard = switch (tokens.len) {
        3 => .{
            .hash = tokens.items[1],
            .deltasrc = null,
            .size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed,
        },
        4 => .{
            .hash = tokens.items[1],
            .deltasrc = tokens.items[2],
            .size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
        },
        else => return CardParseError.Malformed,
    };
    std.debug.assert((result.deltasrc == null) == (tokens.len == 3));
    return result;
}

/// §7.2-r1/r2 — parse `cfile HASH USIZE CSIZE` or
/// `cfile HASH DELTASRC USIZE CSIZE`. Same arity-disambiguation shape as
/// `parseFileCard`.
pub fn parseCfileCard(tokens: Tokens) CardParseError!CfileCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .cfile);
    std.debug.assert(tokens.len <= max_tokens);
    const result: CfileCard = switch (tokens.len) {
        4 => .{
            .hash = tokens.items[1],
            .deltasrc = null,
            .uncompressed_size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed,
            .compressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
        },
        5 => .{
            .hash = tokens.items[1],
            .deltasrc = tokens.items[2],
            .uncompressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
            .compressed_size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed,
        },
        else => return CardParseError.Malformed,
    };
    std.debug.assert((result.deltasrc == null) == (tokens.len == 4));
    return result;
}

/// §7.1-r2/r3/r4, §7.2-r2/r3/r4 — the payload bytes plus the offset just
/// past them.
pub const ContentPayload = struct { bytes: []const u8, next: usize };

/// §7.1-r3/r4, §7.2-r3/r4 — the sole content-frame failure: the declared
/// span runs past the buffer. There is no `MissingNewline` arm (unlike
/// `ConfigFrameError`): §7.1-r4/§7.2-r4 mandate no trailing separator, so
/// that failure mode is defined out of existence here.
pub const ContentFrameError = error{Truncated};

/// §7.1-r2/r3/r4, §7.2-r2/r3/r4 — slice out exactly `size` payload bytes
/// starting at `start`; the byte at `start + size` (if any) is the first
/// byte of the next card line, never consumed. Overflow-safe: the add is
/// checked so an adversarial `size` cannot wrap `usize` into passing the
/// bound check. Shared verbatim by `file` and `cfile`; callers pass
/// CSIZE — never USIZE — for cfile, which structurally enforces §7.2-r3
/// since `CfileCard.uncompressed_size` never reaches this parameter in
/// the framing call sites above.
pub fn consumeContentPayload(body: []const u8, start: usize, size: u32) ContentFrameError!ContentPayload {
    std.debug.assert(start <= body.len);
    const end = std.math.add(usize, start, size) catch return ContentFrameError.Truncated;
    if (end > body.len) return ContentFrameError.Truncated;
    const result: ContentPayload = .{ .bytes = body[start..end], .next = end };
    std.debug.assert(result.next == start + @as(usize, size));
    std.debug.assert(result.bytes.len == size);
    return result;
}

/// §7.4-r1 — one delta held pending its basis: HASH's delta bytes against a
/// DELTASRC this receiver does not yet hold. `delta_bytes` is exactly what
/// `consumeContentPayload` framed off the card; nothing further is decoded
/// until the basis arrives.
pub const PendingDelta = struct {
    hash: []const u8,
    deltasrc: []const u8,
    delta_bytes: []const u8,
};

/// §7.4-r1/r3 — deltas retained against an absent basis, replayed once the
/// basis is stored. Fixed capacity rather than a growable list: a sync round
/// transfers a bounded card set (§4-r8's body cap already bounds the number
/// of cards a single body can carry), so a hard cap here catches a runaway
/// peer instead of hiding it behind unbounded allocation.
pub const PendingDeltaQueue = struct {
    pub const max_pending: usize = 256;
    pub const RetainError = error{QueueFull};

    items: [max_pending]PendingDelta = undefined,
    len: usize = 0,

    /// §7.4-r1 — retain `delta_bytes` against `deltasrc`, deferring
    /// reconstruction to `completeFor`. Does not touch the store: creating
    /// the DELTASRC phantom (§7.4-r2) is the caller's job via `store.intern`.
    pub fn retain(self: *PendingDeltaQueue, hash: []const u8, deltasrc: []const u8, delta_bytes: []const u8) RetainError!void {
        std.debug.assert(self.len <= max_pending);
        if (self.len == max_pending) return RetainError.QueueFull;
        self.items[self.len] = .{ .hash = hash, .deltasrc = deltasrc, .delta_bytes = delta_bytes };
        self.len += 1;
        std.debug.assert(self.len <= max_pending);
    }

    /// §7.4-r3 — `deltasrc` has just been stored (the caller already called
    /// `store.put(deltasrc, ...)`); complete every deferred reconstruction
    /// that named it as basis via `delta.apply`, land the result with
    /// `store.put`, and drop the entry. Swap-remove: the queue carries no
    /// arrival order to preserve. Bounded by `self.len <= max_pending`, so
    /// the scan below is bounded the same as `retain`'s capacity.
    pub fn completeFor(
        self: *PendingDeltaQueue,
        allocator: std.mem.Allocator,
        store: repostate.ArtifactStore,
        deltasrc: []const u8,
    ) (delta.Error || std.mem.Allocator.Error)!void {
        // §7.4-r3 cascades: reconstructing a target STORES it, which is itself a
        // "source artifact subsequently stored" event for any pending delta that
        // named that target as its basis (a delta chain C<-A<-B). Drive the chain
        // to fixpoint via a worklist of newly-stored sources. Bounded: each queue
        // entry completes at most once (swap-removed), so at most `max_pending`
        // target hashes are ever enqueued, plus the initial deltasrc.
        var work: [max_pending + 1][]const u8 = undefined;
        work[0] = deltasrc;
        var work_len: usize = 1;
        var done: usize = 0;
        while (done < work_len) {
            const src = work[done];
            done += 1;
            var i: usize = 0;
            while (i < self.len) {
                std.debug.assert(i < max_pending);
                if (!std.mem.eql(u8, self.items[i].deltasrc, src)) {
                    i += 1;
                    continue;
                }
                const source_rid = try store.intern(src);
                // Precondition: `src` is stored before it enters the worklist —
                // the caller stored the initial deltasrc, and every cascaded src
                // was just put() below — so it is never a phantom here.
                const source_bytes = store.content(source_rid) orelse unreachable;
                const reconstructed = try delta.apply(allocator, source_bytes, self.items[i].delta_bytes);
                defer allocator.free(reconstructed);
                const target_hash = self.items[i].hash;
                _ = try store.put(target_hash, reconstructed);
                // The target is now stored: enqueue it as a basis for any other
                // pending delta (§7.4-r3 cascade).
                std.debug.assert(work_len <= max_pending);
                work[work_len] = target_hash;
                work_len += 1;
                self.len -= 1;
                self.items[i] = self.items[self.len];
                // Do not advance i: index i now holds the swapped-in element.
            }
        }
    }
};

test "sync §7.1-r1: file card parses in both two- and four-argument forms" {
    const three = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " 42"));
    try std.testing.expectEqualStrings(test_hash_a, three.hash);
    try std.testing.expectEqual(@as(?[]const u8, null), three.deltasrc);
    try std.testing.expectEqual(@as(u32, 42), three.size);

    const four = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 42"));
    try std.testing.expectEqualStrings(test_hash_a, four.hash);
    try std.testing.expectEqualStrings(test_hash_b, four.deltasrc.?);
    try std.testing.expectEqual(@as(u32, 42), four.size);
}

test "sync §7.1-r2: file SIZE counts payload bytes; payload may contain 0x0a" {
    const body = "file " ++ test_hash_a ++ " 5\nAB\nCDtail";
    const line = try readLine(body, 0);
    const card = try parseFileCard(tokenizeCard(line.bytes));
    try std.testing.expectEqual(@as(u32, 5), card.size);
    const payload = try consumeContentPayload(body, line.next, card.size);
    try std.testing.expectEqualStrings("AB\nCD", payload.bytes);
}

test "sync §7.1-r3: file payload end located by count, not separator scan (rejection)" {
    // A naive LF-scan would stop at "AB" (2 bytes); byte-count framing
    // must instead consume the full 5-byte payload "AB\nCD" — the
    // embedded LF at offset 2 is not a terminator.
    const body = "file " ++ test_hash_a ++ " 5\nAB\nCD";
    const line = try readLine(body, 0);
    const card = try parseFileCard(tokenizeCard(line.bytes));
    const payload = try consumeContentPayload(body, line.next, card.size);
    try std.testing.expectEqualStrings("AB\nCD", payload.bytes);
    try std.testing.expect(payload.bytes.len != 2);
}

test "sync §7.1-r4: no trailing separator; next byte begins the next card" {
    const body = "file " ++ test_hash_a ++ " 3\nXYZgimme " ++ test_hash_b;
    const line = try readLine(body, 0);
    const card = try parseFileCard(tokenizeCard(line.bytes));
    const payload = try consumeContentPayload(body, line.next, card.size);
    try std.testing.expectEqual(line.next + 3, payload.next);
    try std.testing.expectEqualStrings("gimme " ++ test_hash_b, body[payload.next..]);
}

test "sync §7.1-r5: two-arg form is full content, four-arg carries a delta basis" {
    const full = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " 10"));
    try std.testing.expectEqual(@as(?[]const u8, null), full.deltasrc);
    const delta_form = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10"));
    try std.testing.expectEqualStrings(test_hash_b, delta_form.deltasrc.?);
}

// §A.1 L2998-3035 (Round 2 request of the two-round push/pull worked
// example) — the vector holds the first file card's 272-byte manifest
// payload, the second file card's 26-byte payload, and enough of the
// following gimme card to prove the "no separator" boundary lands
// exactly where the RFC's own byte counts say it does.
const a1_round2_file_cards = @embedFile("rfc_vectors/sync_a1_round2_file_cards.txt");

test "sync §7.1: worked example — first file card frames the 272-byte manifest (§A.1 L3003-3031)" {
    const body = a1_round2_file_cards;
    const line = try readLine(body, 0);
    const card = try parseFileCard(tokenizeCard(line.bytes));
    try std.testing.expectEqualStrings("8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259", card.hash);
    try std.testing.expectEqual(@as(?[]const u8, null), card.deltasrc);
    // RFC L3029-3031: "seven lines of Fossil manifest notation, totalling
    // 272 bytes including terminating LFs" — 26+26+77+67+35+6+35 = 272.
    try std.testing.expectEqual(@as(u32, 272), card.size);
    const payload = try consumeContentPayload(body, line.next, card.size);
    try std.testing.expectEqualStrings(
        "C client:\\sadd\\salpha.txt\n" ++
            "D 2026-07-02T18:30:46.860\n" ++
            "F alpha.txt b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15\n" ++
            "P c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57\n" ++
            "R 6b3e7488d36fc39ea8093fdea87c3641\n" ++
            "U drh\n" ++
            "Z ed00cdc67449c3949587aa83b3fd8a67\n",
        payload.bytes,
    );
    // No trailing separator: the very next byte starts the second file card.
    const next_line = try readLine(body, payload.next);
    try std.testing.expectEqualStrings(
        "file b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15 26",
        next_line.bytes,
    );
}

test "sync §7.1: worked example — second file card frames the 26-byte payload (§A.1 L3011-3034)" {
    const body = a1_round2_file_cards;
    const first_line = try readLine(body, 0);
    const first_card = try parseFileCard(tokenizeCard(first_line.bytes));
    const first_payload = try consumeContentPayload(body, first_line.next, first_card.size);

    const line = try readLine(body, first_payload.next);
    const card = try parseFileCard(tokenizeCard(line.bytes));
    try std.testing.expectEqualStrings("b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15", card.hash);
    // RFC L3032-3034: "the text alpha content from client followed by a
    // single LF" — 25 text bytes + 1 LF = 26.
    try std.testing.expectEqual(@as(u32, 26), card.size);
    const payload = try consumeContentPayload(body, line.next, card.size);
    try std.testing.expectEqualStrings("alpha content from client\n", payload.bytes);
    // No trailing separator: the next byte starts the following gimme card.
    const next_line = try readLine(body, payload.next);
    try std.testing.expectEqualStrings(
        "gimme 4361252d0f450879dcd2e0b6a42b95ffe8a41d8e948130253ae8eefa7f4b79ea",
        next_line.bytes,
    );
}

test "sync §7.2-r1: cfile card parses in both three- and four-value forms" {
    const three = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " 100 40"));
    try std.testing.expectEqualStrings(test_hash_a, three.hash);
    try std.testing.expectEqual(@as(?[]const u8, null), three.deltasrc);
    try std.testing.expectEqual(@as(u32, 100), three.uncompressed_size);
    try std.testing.expectEqual(@as(u32, 40), three.compressed_size);

    const four = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " 100 40"));
    try std.testing.expectEqualStrings(test_hash_b, four.deltasrc.?);
    try std.testing.expectEqual(@as(u32, 100), four.uncompressed_size);
    try std.testing.expectEqual(@as(u32, 40), four.compressed_size);
}

test "sync §7.2-r2: CSIZE counts payload bytes; USIZE is the reconstructed size" {
    const card = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " 1000 12"));
    try std.testing.expectEqual(@as(u32, 1000), card.uncompressed_size);
    try std.testing.expectEqual(@as(u32, 12), card.compressed_size);
}

test "sync §7.2-r3: payload framed by CSIZE, never USIZE (rejection)" {
    // USIZE (999999) vastly exceeds the body; a framer that mistakenly
    // used USIZE would overrun. `consumeContentPayload` sees only the
    // caller-selected size argument — passing CSIZE succeeds, passing
    // USIZE (proving what would happen if the wrong field reached the
    // framer) is Truncated.
    const body = "cfile " ++ test_hash_a ++ " 999999 5\nHELLOtail";
    const line = try readLine(body, 0);
    const card = try parseCfileCard(tokenizeCard(line.bytes));
    const payload = try consumeContentPayload(body, line.next, card.compressed_size);
    try std.testing.expectEqualStrings("HELLO", payload.bytes);
    try std.testing.expectError(ContentFrameError.Truncated, consumeContentPayload(body, line.next, card.uncompressed_size));
}

test "sync §7.2-r4: no trailing separator after the cfile payload" {
    const body = "cfile " ++ test_hash_a ++ " 100 3\nXYZgimme " ++ test_hash_b;
    const line = try readLine(body, 0);
    const card = try parseCfileCard(tokenizeCard(line.bytes));
    const payload = try consumeContentPayload(body, line.next, card.compressed_size);
    try std.testing.expectEqual(line.next + 3, payload.next);
    try std.testing.expectEqualStrings("gimme " ++ test_hash_b, body[payload.next..]);
}

test "sync §7.2-r5: USIZE parsed for syntax but not a frame boundary" {
    // USIZE (3) mismatches the 5 wire bytes CSIZE actually declares; the
    // card still parses and the payload still frames — current receivers
    // never cross-check USIZE against the received payload.
    const body = "cfile " ++ test_hash_a ++ " 3 5\nHELLOtail";
    const line = try readLine(body, 0);
    const card = try parseCfileCard(tokenizeCard(line.bytes));
    try std.testing.expectEqual(@as(u32, 3), card.uncompressed_size);
    const payload = try consumeContentPayload(body, line.next, card.compressed_size);
    try std.testing.expectEqual(@as(usize, 5), payload.bytes.len);
}

test "sync §7.2-r6: cfile payload framed verbatim; framer neither decompresses nor re-compresses" {
    // The payload is opaque stored-container bytes (a fabricated 4-byte
    // big-endian length header plus zlib-stream-shaped bytes, per §4.1's
    // container — the exact codec is out of scope here); the framer is a
    // byte-count slice, not a codec, so bytes in equal bytes out.
    const stored_container = [_]u8{ 0x00, 0x00, 0x00, 0x05, 0x78, 0x9c, 0x01, 0x02 };
    const body = "cfile " ++ test_hash_a ++ " 5 8\n" ++ stored_container;
    const line = try readLine(body, 0);
    const card = try parseCfileCard(tokenizeCard(line.bytes));
    const payload = try consumeContentPayload(body, line.next, card.compressed_size);
    try std.testing.expectEqualSlices(u8, &stored_container, payload.bytes);
}

test "sync §7.2-r7: x-fossil-uncompressed outer body means the payload is not double-compressed" {
    // §4.1: x-fossil-uncompressed's outer body is not zlib-wrapped
    // (outer_compressed == false); consumeContentPayload applies no
    // compression of its own, so the cfile payload it frames from that
    // outer body is exactly the stored-container bytes — never wrapped
    // a second time.
    try std.testing.expect(!media_props.get(.x_fossil_uncompressed).outer_compressed);
    const stored_container = [_]u8{ 0x00, 0x00, 0x00, 0x02, 0x78, 0x9c };
    const body = "cfile " ++ test_hash_a ++ " 2 6\n" ++ stored_container;
    const line = try readLine(body, 0);
    const card = try parseCfileCard(tokenizeCard(line.bytes));
    const payload = try consumeContentPayload(body, line.next, card.compressed_size);
    try std.testing.expectEqualSlices(u8, &stored_container, payload.bytes);
}

test "sync §7.1/§7.2: content payload whose span exceeds the body is Truncated (rejection)" {
    const file_body = "file " ++ test_hash_a ++ " 100\nshort";
    const file_line = try readLine(file_body, 0);
    const file_card = try parseFileCard(tokenizeCard(file_line.bytes));
    try std.testing.expectError(ContentFrameError.Truncated, consumeContentPayload(file_body, file_line.next, file_card.size));

    const cfile_body = "cfile " ++ test_hash_a ++ " 5 100\nshort";
    const cfile_line = try readLine(cfile_body, 0);
    const cfile_card = try parseCfileCard(tokenizeCard(cfile_line.bytes));
    try std.testing.expectError(ContentFrameError.Truncated, consumeContentPayload(cfile_body, cfile_line.next, cfile_card.compressed_size));
}

test "sync §7.1/§7.2: wrong arity or non-numeric size is Malformed (rejection)" {
    const file_cases = [_][]const u8{
        "file " ++ test_hash_a, // arity 2: missing SIZE
        "file " ++ test_hash_a ++ " x", // non-numeric SIZE
        "file " ++ test_hash_a ++ " " ++ test_hash_b ++ " x", // non-numeric SIZE, four-arg
        "file " ++ test_hash_a ++ " 1 2 3", // arity 5: neither 3 nor 4
    };
    for (file_cases) |line| {
        try std.testing.expectError(CardParseError.Malformed, parseFileCard(tokenizeCard(line)));
    }

    const cfile_cases = [_][]const u8{
        "cfile " ++ test_hash_a, // arity 2
        "cfile " ++ test_hash_a ++ " 10", // arity 3: missing CSIZE
        "cfile " ++ test_hash_a ++ " x 10", // non-numeric USIZE
        "cfile " ++ test_hash_a ++ " 10 x", // non-numeric CSIZE
        "cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " x 10", // non-numeric USIZE, five-arg
        "cfile " ++ test_hash_a ++ " 1 2 3 4", // arity 6: neither 4 nor 5
    };
    for (cfile_cases) |line| {
        try std.testing.expectError(CardParseError.Malformed, parseCfileCard(tokenizeCard(line)));
    }
}

test "sync §7.1/§7.2 fuzz: parse* never crash on adversarial tokens" {
    // Untrusted wire tokens: parseFileCard/parseCfileCard must never
    // crash, only return a declared error or a card whose deltasrc
    // presence matches the arity that produced it.
    var prng = std.Random.DefaultPrng.init(0x71C0_F11E);
    const rand = prng.random();
    const max_field: usize = 24;
    var field_buf: [max_tokens][max_field]u8 = undefined;
    var round: usize = 0;
    while (round < 500) : (round += 1) {
        var tokens: Tokens = .{};
        const n = 1 + rand.uintLessThan(usize, max_tokens);
        tokens.len = n;
        var i: usize = 1;
        while (i < n) : (i += 1) {
            const len = rand.uintLessThan(usize, max_field + 1);
            for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
            tokens.items[i] = field_buf[i][0..len];
        }

        tokens.items[0] = "file";
        if (parseFileCard(tokens)) |card| {
            try std.testing.expect((card.deltasrc == null) == (n == 3));
        } else |_| {}

        tokens.items[0] = "cfile";
        if (parseCfileCard(tokens)) |card| {
            try std.testing.expect((card.deltasrc == null) == (n == 4));
        } else |_| {}
    }
}

test "sync §7 fuzz: consumeContentPayload never overruns on adversarial (start,size)" {
    var prng = std.Random.DefaultPrng.init(0xC0FF_EE07);
    const rand = prng.random();
    var body_buf: [64]u8 = undefined;
    for (&body_buf) |*b| b.* = rand.int(u8);
    var round: usize = 0;
    while (round < 1000) : (round += 1) {
        const start = rand.uintLessThan(usize, body_buf.len + 1);
        const size: u32 = rand.int(u32);
        if (consumeContentPayload(&body_buf, start, size)) |payload| {
            try std.testing.expect(payload.next <= body_buf.len);
            try std.testing.expectEqual(@as(usize, size), payload.bytes.len);
        } else |_| {}
    }
}

test "sync §7.4-r2: file card naming an unheld DELTASRC is accepted, not rejected" {
    const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10"));
    try std.testing.expectEqualStrings(test_hash_b, card.deltasrc.?);
}

test "sync §7.4-r2: cfile card naming an unheld DELTASRC is accepted, not rejected" {
    const card = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " 100 40"));
    try std.testing.expectEqualStrings(test_hash_b, card.deltasrc.?);
}

// §7.4-r2 — the receiver "MUST create a phantom record for the named
// DELTASRC". A phantom is `ArtifactStore`'s own contract (content(rid) ==
// null), so this is asserted directly against the store, mirroring the
// §3.3 r24 store-contract test above: no sync-layer isPhantom wrapper.
test "sync §7.4-r2: creating a phantom for the DELTASRC (store contract, §3.3)" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();
    const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10"));
    const rid = try store.intern(card.deltasrc.?);
    try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid));
}

test "sync §7.4-r1: delta against an unheld source is retained, not applied" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();
    const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10"));
    const rid = try store.intern(card.deltasrc.?); // §7.4-r2 phantom, prerequisite for retention

    var queue = PendingDeltaQueue{};
    const delta_bytes = "opaque delta payload, not yet decoded";
    try queue.retain(card.hash, card.deltasrc.?, delta_bytes);

    try std.testing.expectEqual(@as(usize, 1), queue.len);
    try std.testing.expectEqualStrings(card.hash, queue.items[0].hash);
    try std.testing.expectEqualStrings(card.deltasrc.?, queue.items[0].deltasrc);
    try std.testing.expectEqualStrings(delta_bytes, queue.items[0].delta_bytes);
    // Reconstruction is deferred: the source is still a phantom and HASH was
    // never interned, so neither side has stored content yet.
    try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid));
}

test "sync §7.4-r1: retention queue rejects beyond its fixed capacity (bound)" {
    var queue = PendingDeltaQueue{};
    var round: usize = 0;
    while (round < PendingDeltaQueue.max_pending) : (round += 1) {
        try queue.retain(test_hash_a, test_hash_b, "d");
    }
    try std.testing.expectEqual(PendingDeltaQueue.max_pending, queue.len);
    try std.testing.expectError(PendingDeltaQueue.RetainError.QueueFull, queue.retain(test_hash_a, test_hash_b, "d"));
}

test "sync §7.4-r3: storing the deferred source completes the reconstruction" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();

    const source = "the source artifact's stored bytes, pre-delta";
    const target = "the source artifact's stored bytes, post-delta, longer";
    const delta_bytes = try delta.create(std.testing.allocator, source, target);
    defer std.testing.allocator.free(delta_bytes);

    const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 1"));
    _ = try store.intern(card.deltasrc.?); // §7.4-r2 phantom before the source arrives

    var queue = PendingDeltaQueue{};
    try queue.retain(card.hash, card.deltasrc.?, delta_bytes);

    // The source artifact is subsequently stored (§7.4-r3's trigger).
    const source_rid = try store.put(card.deltasrc.?, source);
    try queue.completeFor(std.testing.allocator, store, card.deltasrc.?);

    try std.testing.expectEqual(@as(usize, 0), queue.len);
    try std.testing.expectEqualStrings(source, store.content(source_rid).?);
    const hash_rid = try store.intern(card.hash);
    try std.testing.expectEqualStrings(target, store.content(hash_rid).?);
}

test "sync §7.4-r3: completion cascades through a delta chain to fixpoint (C<-A<-B)" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();
    const test_hash_c = "c" ** 40;

    const b_bytes = "artifact B: the eventual on-wire source, base of the chain";
    const a_bytes = "artifact A: reconstructed from B and itself the basis for C, longer";
    const c_bytes = "artifact C: reconstructed from A, the tail of the chain, longer still";
    const d_a = try delta.create(std.testing.allocator, b_bytes, a_bytes); // A from B
    defer std.testing.allocator.free(d_a);
    const d_c = try delta.create(std.testing.allocator, a_bytes, c_bytes); // C from A
    defer std.testing.allocator.free(d_c);

    // C and A both arrive before B: C names A as basis, A names B as basis.
    _ = try store.intern(test_hash_a); // §7.4-r2 phantom for A (C's source)
    _ = try store.intern(test_hash_b); // §7.4-r2 phantom for B (A's source)
    var queue = PendingDeltaQueue{};
    try queue.retain(test_hash_c, test_hash_a, d_c);
    try queue.retain(test_hash_a, test_hash_b, d_a);

    // B is stored; completing for B must cascade A -> C to fixpoint, else C is a
    // permanent phantom (§7.4-r3) and §3.7 convergence breaks.
    _ = try store.put(test_hash_b, b_bytes);
    try queue.completeFor(std.testing.allocator, store, test_hash_b);

    try std.testing.expectEqual(@as(usize, 0), queue.len);
    try std.testing.expectEqualStrings(a_bytes, store.content(try store.intern(test_hash_a)).?);
    try std.testing.expectEqualStrings(c_bytes, store.content(try store.intern(test_hash_c)).?);
}

test "sync §7.4-r3: completeFor only completes entries whose deltasrc matches" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();

    const source_b = "source b bytes";
    const target_a = "target a bytes, reconstructed from source b";
    const delta_ab = try delta.create(std.testing.allocator, source_b, target_a);
    defer std.testing.allocator.free(delta_ab);
    const other_source = "c" ** 40;
    const delta_ac = "opaque delta against a different, still-unheld source";

    var queue = PendingDeltaQueue{};
    try queue.retain(test_hash_a, test_hash_b, delta_ab);
    try queue.retain(test_hash_a, other_source, delta_ac);

    _ = try store.put(test_hash_b, source_b);
    try queue.completeFor(std.testing.allocator, store, test_hash_b);

    try std.testing.expectEqual(@as(usize, 1), queue.len);
    try std.testing.expectEqualStrings(other_source, queue.items[0].deltasrc);
    const rid_a = try store.intern(test_hash_a);
    try std.testing.expectEqualStrings(target_a, store.content(rid_a).?);
}

fn completeForNoLeak(allocator: std.mem.Allocator) !void {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const store = ms.store();
    const source = "no-leak source bytes";
    const target = "no-leak target bytes, a bit longer than the source";
    const delta_bytes = try delta.create(std.testing.allocator, source, target);
    defer std.testing.allocator.free(delta_bytes);

    var queue = PendingDeltaQueue{};
    try queue.retain(test_hash_a, test_hash_b, delta_bytes);
    _ = try store.put(test_hash_b, source);
    try queue.completeFor(allocator, store, test_hash_b);
}

test "sync §7.4-r3: completeFor propagates allocation failure without leaking" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, completeForNoLeak, .{});
}

// ---------------------------------------------------------------------------
// Section 7.5 — Private Content
// ---------------------------------------------------------------------------

/// §7.5 L1743-1746 (r1/r2) — the card that must immediately follow a
/// `private` card: content (a file/cfile card carrying the private
/// payload), or a protocol error. `follower` null covers "nothing
/// follows" (also a protocol error). A private card before a uvfile is
/// undefined by the RFC (L1754-1756); ambiguities.md §7.5 r6 (RESOLVED,
/// reading B) routes it here through the same content-bearing-minus-
/// uvfile predicate as every other non-file/cfile follower.
pub const PrivatePairing = enum { content, protocol_error };

pub fn privatePairing(follower: ?Operator) PrivatePairing {
    const op = follower orelse return .protocol_error;
    const result: PrivatePairing = if (isContentBearing(op) and op != .uvfile) .content else .protocol_error;
    std.debug.assert((result == .content) == (follower != null and isContentBearing(follower.?) and follower.? != .uvfile));
    return result;
}

/// §7.5 L1750-1752 (r4/r5) — the sole holds-Private gate on receipt: an
/// artifact is stored only when the authenticated user holds the
/// Private capability; otherwise it is discarded without error (r5) —
/// the plain enum, not an error union, is what defines "without error"
/// out of existence.
pub const PrivateReceipt = enum { store, discard };

pub fn privateReceipt(user_has_private: bool) PrivateReceipt {
    const result: PrivateReceipt = if (user_has_private) .store else .discard;
    std.debug.assert((result == .store) == user_has_private);
    return result;
}

/// §7.5 L1758-1764 (r7) — send-side delta-basis gate. Precondition: the
/// artifact being sent is itself public — the boundary only bites when a
/// *public* artifact's on-disk delta basis is private. Sending such a
/// delta to a peer lacking private-content authorization would leak the
/// basis artifact's existence/identity, so the sender is forced to full,
/// non-delta content instead.
pub const DeltaBasisSend = enum { delta_ok, force_full };

pub fn deltaBasisSend(basis_private: bool, peer_has_private: bool) DeltaBasisSend {
    const result: DeltaBasisSend = if (basis_private and !peer_has_private) .force_full else .delta_ok;
    std.debug.assert((result == .force_full) == (basis_private and !peer_has_private));
    return result;
}

/// §7.5 L1769-1774 (r9/r10/r11/r12) — receive-side disposition of a
/// private `igot HASH 1` from a peer that lacks private-content
/// authorization: never an error (r9), never creates a phantom (r10),
/// marks an already-held phantom private (r11), and always records that
/// the peer holds HASH (r12). `already_phantom` is the caller's lookup
/// of its own phantom table; the caller applies `mark_phantom_private`
/// back to that table — same returned-decision contract as
/// `igotDisposition`. No repostate code lands here.
pub const PrivateIgotReceipt = struct {
    is_error: bool,
    create_phantom: bool,
    mark_phantom_private: bool,
    record_peer_has: bool,
};

pub fn receivePrivateIgot(already_phantom: bool) PrivateIgotReceipt {
    const result: PrivateIgotReceipt = .{
        .is_error = false,
        .create_phantom = false,
        .mark_phantom_private = already_phantom,
        .record_peer_has = true,
    };
    std.debug.assert(!result.is_error and !result.create_phantom and
        result.record_peer_has and result.mark_phantom_private == already_phantom);
    return result;
}

/// §9.2 L2191-2192 — a parsed `igot HASH` (public) or `igot HASH 1`
/// (private) card. `1` is the only flag value this grammar defines.
pub const IgotCard = struct { hash: []const u8, private: bool };

/// §9.2 L2191-2192 — parse `igot HASH` or `igot HASH 1`. Arity alone
/// disambiguates the two forms, same shape as `parseFileCard`/
/// `parseUvigotCard`; a third token other than the literal "1" is
/// malformed, and any arity outside {2,3} is malformed.
pub fn parseIgotCard(tokens: Tokens) CardParseError!IgotCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .igot);
    const result: IgotCard = switch (tokens.len) {
        2 => .{ .hash = tokens.items[1], .private = false },
        3 => blk: {
            if (!std.mem.eql(u8, tokens.items[2], "1")) return CardParseError.Malformed;
            break :blk .{ .hash = tokens.items[1], .private = true };
        },
        else => return CardParseError.Malformed,
    };
    std.debug.assert(!result.private or tokens.len == 3);
    return result;
}

test "sync §7.5 r1: a private card is immediately followed by a file or cfile card" {
    try std.testing.expectEqual(PrivatePairing.content, privatePairing(.file));
    try std.testing.expectEqual(PrivatePairing.content, privatePairing(.cfile));
}

test "sync §7.5 r2: a private card not followed by a content-bearing file/cfile is a protocol error (rejection)" {
    const followers = [_]?Operator{ .igot, .gimme, .private, .@"error", .pragma, null };
    for (followers) |f| try std.testing.expectEqual(PrivatePairing.protocol_error, privatePairing(f));
}

test "sync §7.5 r3: private content transfers only with send-private accepted AND the receiving user holds Private" {
    // Send half — §3.3 gimmeEligible (:2697): a private phantom is
    // gimme-eligible for the round only once private sync is enabled.
    try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false }));
    try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true }));
    // Receive half — the receiving user must separately hold Private.
    try std.testing.expectEqual(PrivateReceipt.discard, privateReceipt(false));
    try std.testing.expectEqual(PrivateReceipt.store, privateReceipt(true));
}

test "sync §7.5 r4: a receiver stores a private artifact only when the user holds Private" {
    try std.testing.expectEqual(PrivateReceipt.store, privateReceipt(true));
}

test "sync §7.5 r5: a private artifact without the capability is discarded without error (rejection)" {
    try std.testing.expectEqual(PrivateReceipt.discard, privateReceipt(false));
}

test "sync §7.5 r6: a private card before a uvfile is an undefined pairing routed to the r2 protocol error (rejection)" {
    try std.testing.expectEqual(PrivatePairing.protocol_error, privatePairing(.uvfile));
}

test "sync §7.5 r7: a delta on a private basis to an unauthorized peer forces full content" {
    const Case = struct { basis_private: bool, peer_has_private: bool, want: DeltaBasisSend };
    const cases = [_]Case{
        .{ .basis_private = true, .peer_has_private = false, .want = .force_full },
        .{ .basis_private = true, .peer_has_private = true, .want = .delta_ok },
        .{ .basis_private = false, .peer_has_private = false, .want = .delta_ok },
        .{ .basis_private = false, .peer_has_private = true, .want = .delta_ok },
    };
    for (cases) |c| try std.testing.expectEqual(c.want, deltaBasisSend(c.basis_private, c.peer_has_private));
}

test "sync §7.5 r8: a private file/cfile pushed without the Private capability is rejected as not-authorized (rejection)" {
    try std.testing.expectEqual(SendPrivateDecision.reject, serverSendPrivateDecision(false));
    try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
    try std.testing.expectEqual(ErrorReset.append, serverErrorReset(.unauthorized_send_private));
    try std.testing.expectEqualStrings("not authorized to sync private content", send_private_unauthorized_text);
}

test "sync §7.5 r9: a private igot HASH 1 from an unprivileged peer is not an error" {
    try std.testing.expect(!receivePrivateIgot(true).is_error);
    try std.testing.expect(!receivePrivateIgot(false).is_error);
}

test "sync §7.5 r10: the receiver does not create a phantom for a private igot HASH" {
    try std.testing.expect(!receivePrivateIgot(true).create_phantom);
    try std.testing.expect(!receivePrivateIgot(false).create_phantom);
}

test "sync §7.5 r11: an already-held phantom is marked private on a private igot HASH" {
    try std.testing.expect(receivePrivateIgot(true).mark_phantom_private);
    try std.testing.expect(!receivePrivateIgot(false).mark_phantom_private);
}

test "sync §7.5 r12: the receiver records the peer holds HASH and continues" {
    try std.testing.expect(receivePrivateIgot(true).record_peer_has);
    try std.testing.expect(receivePrivateIgot(false).record_peer_has);
}

test "sync §9.2: igot HASH is public; igot HASH 1 is the private-flag form" {
    // §A.3 Scenario 4 round-1 reply, L3254 (public) and round-2 reply,
    // L3257 (private).
    const pub_card = try parseIgotCard(tokenizeCard("igot 23813573dea5ec687052a067866751f481e58ef4c478939ac601587a43f50b35"));
    try std.testing.expect(!pub_card.private);
    try std.testing.expectEqualStrings("23813573dea5ec687052a067866751f481e58ef4c478939ac601587a43f50b35", pub_card.hash);

    const priv_card = try parseIgotCard(tokenizeCard("igot 393953a9803595823fb560ddbe333767840a5ad89748a12fc2cd00f533c6df8a 1"));
    try std.testing.expect(priv_card.private);
    try std.testing.expectEqualStrings("393953a9803595823fb560ddbe333767840a5ad89748a12fc2cd00f533c6df8a", priv_card.hash);
}

test "sync §9.2: an igot flag other than 1 is malformed; wrong arity is malformed (rejection)" {
    const bad_flags = [_][]const u8{ "igot HASH 2", "igot HASH 0", "igot HASH x" };
    for (bad_flags) |line| try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard(line)));
    try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard("igot")));
    try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard("igot HASH 1 extra")));
}

test "sync §9.2 fuzz: parseIgotCard never crashes on adversarial tokens" {
    // Untrusted wire tokens: parseIgotCard must never crash, only return
    // a declared error or a card whose private flag is set solely at
    // arity 3 (never fabricates the flag out of arbitrary bytes).
    var prng = std.Random.DefaultPrng.init(0x1607_15CA);
    const rand = prng.random();
    const max_field: usize = 24;
    var field_buf: [max_tokens][max_field]u8 = undefined;
    var round: usize = 0;
    while (round < 500) : (round += 1) {
        var tokens: Tokens = .{};
        const n = 1 + rand.uintLessThan(usize, max_tokens);
        tokens.len = n;
        var i: usize = 1;
        while (i < n) : (i += 1) {
            const len = rand.uintLessThan(usize, max_field + 1);
            for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
            tokens.items[i] = field_buf[i][0..len];
        }

        tokens.items[0] = "igot";
        if (parseIgotCard(tokens)) |card| {
            try std.testing.expect(!card.private or tokens.len == 3);
        } else |_| {}
    }
}

// ---------------------------------------------------------------------------
// Section 7.7 — Hash-Policy Admission
// ---------------------------------------------------------------------------

/// §7.7 L1818-1824 — hash-policy admission: a gate on the inbound artifact's
/// HASH NAME (length alone → algorithm, artifact.nameAlgo), governed by the
/// receiver's configured policy. Distinct from content-integrity (r1): it
/// never recomputes or inspects content. Under shun-sha1 a 40-char (SHA1)
/// name is rejected before storage (r2); every other policy admits. One
/// function serves all three call sites — file, cfile, and cfile on the
/// clone 3 SEQNO path (r3/r3b) — since the decision depends only on
/// (policy, name), never on the card type or path.
pub const Admission = enum { admit, reject };

pub fn admitInbound(policy: artifact.HashPolicy, hash: []const u8) Admission {
    std.debug.assert(artifact.nameAlgo(hash.len) != null); // parse layer validated the name
    const result: Admission = if (policy == .shun_sha1 and hash.len == 40) .reject else .admit;
    std.debug.assert((result == .reject) == (policy == .shun_sha1 and hash.len == 40));
    return result;
}

/// §7.7 L1826-1832 — the content-integrity check-point schedule for one
/// inbound content card. `pre_store` is the transfer-layer recompute-over-
/// reconstructed-bytes check (artifact.verify on the delta-applied content,
/// not the wire payload — r5); it fires only on non-clone file cards (r6).
/// cfile cards never pre-store-verify in the transfer layer (r7). `at_commit`
/// is the universal: commit-time verification applies on ALL paths (r6/r7
/// second clauses) — always true, pinned by assertion and by every test combo.
pub const IntegritySchedule = struct { pre_store: bool, at_commit: bool };

pub fn integritySchedule(op: Operator, clone_path: bool) IntegritySchedule {
    std.debug.assert(op == .file or op == .cfile); // §7.7 governs file/cfile; uvfile is §7.3-r13
    const result: IntegritySchedule = .{ .pre_store = (op == .file and !clone_path), .at_commit = true };
    std.debug.assert(result.at_commit); // commit-time on all paths, no branch may drop it
    std.debug.assert(result.pre_store == (op == .file and !clone_path));
    return result;
}

test "sync §7.7 L1814-1816 r1: admission and integrity are distinct mechanisms (not conflated)" {
    // Under .sha1 policy a 40-hex name is admitted regardless of content —
    // admission never inspects bytes, so a corrupt payload still admits.
    var buf: [64]u8 = undefined;
    const good_content = "distinct-axes-content";
    const sha1_name = artifact.artifactName(.sha1, good_content, &buf);
    var name_copy: [40]u8 = undefined;
    @memcpy(&name_copy, sha1_name);
    try std.testing.expectEqual(Admission.admit, admitInbound(.sha1, &name_copy));
    try std.testing.expect(!artifact.verify(&name_copy, "corrupted-different-bytes"));

    // Under .shun_sha1 a valid SHA3 name both admits AND verifies — the two
    // decisions are separable, not fused into one predicate.
    var buf2: [64]u8 = undefined;
    const sha3_name = artifact.artifactName(.sha3_256, good_content, &buf2);
    var name3_copy: [64]u8 = undefined;
    @memcpy(&name3_copy, sha3_name);
    try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, &name3_copy));
    try std.testing.expect(artifact.verify(&name3_copy, good_content));
}

test "sync §7.7 L1820-1822 r2: shun-sha1 rejects an inbound 40-hex SHA1 name before storage (rejection)" {
    const sha1_name = "a" ** 40;
    const sha3_name = "a" ** 64;
    try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name));
    try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, sha3_name));
}

test "sync §7.7 L1822-1824 r3: the admission gate applies identically at file and cfile sites" {
    // One decision function, no card-type parameter: file and cfile see the
    // same rejection for the same (policy, name) pair.
    const sha1_name = "a" ** 40;
    const sites = [_]Operator{ .file, .cfile };
    for (sites) |site| {
        _ = site; // the gate takes no card-type input — this loop documents that both sites route through it identically
        try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name));
    }
}

test "sync §7.7 L1823-1824 r3b: a cfile on the clone 3 SEQNO path is admission-gated too (rejection)" {
    const sha1_name = "a" ** 40;
    // clone_seqno is the Operator naming the clone 3 SEQNO path; the cfile
    // card it carries is still subject to the same admission gate.
    _ = Operator.clone_seqno;
    try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name));
}

test "sync §7.7 L1818-1820 distinction: admission is governed by the configured hash policy, not content" {
    const sha1_name = "a" ** 40;
    const admitting_policies = [_]artifact.HashPolicy{ .sha1, .auto, .sha3, .sha3_only };
    for (admitting_policies) |p| {
        try std.testing.expectEqual(Admission.admit, admitInbound(p, sha1_name));
    }
    try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name));
}

test "sync §7.7 L1826-1829 r5: integrity recomputes over reconstructed bytes, not the wire delta payload" {
    const source = "the quick brown fox jumps over the lazy dog, repeatedly and at length";
    const target = "the quick brown fox jumps over the lazy dog, repeatedly and at length, plus more";
    const d = try delta.create(std.testing.allocator, source, target);
    defer std.testing.allocator.free(d);

    var buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, target, &buf);
    var name_copy: [64]u8 = undefined;
    @memcpy(&name_copy, name);

    const reconstructed = try delta.apply(std.testing.allocator, source, d);
    defer std.testing.allocator.free(reconstructed);

    try std.testing.expect(artifact.verify(&name_copy, reconstructed));
    try std.testing.expect(!artifact.verify(&name_copy, d)); // wire delta bytes, not reconstructed content
}

test "sync §7.7 L1829-1830 r6: pre-storage integrity on non-clone file cards; commit-time on all paths" {
    try std.testing.expectEqual(IntegritySchedule{ .pre_store = true, .at_commit = true }, integritySchedule(.file, false));
    try std.testing.expect(!integritySchedule(.file, true).pre_store);

    const ops = [_]Operator{ .file, .cfile };
    const paths = [_]bool{ false, true };
    for (ops) |op| {
        for (paths) |clone| {
            try std.testing.expect(integritySchedule(op, clone).at_commit);
        }
    }
}

test "sync §7.7 L1830-1832 r7: cfile pre-storage integrity does not apply in transfer; commit-time still applies" {
    try std.testing.expectEqual(IntegritySchedule{ .pre_store = false, .at_commit = true }, integritySchedule(.cfile, false));
    try std.testing.expectEqual(IntegritySchedule{ .pre_store = false, .at_commit = true }, integritySchedule(.cfile, true));
}

// ---------------------------------------------------------------------------
// Section 7.3 — uvfile Card
// ---------------------------------------------------------------------------

/// §7.3-r7 — FLAGS bit 0x0001: this uvfile represents a deletion.
pub const uvfile_flag_deletion: u32 = 0x0001;
/// §7.3-r7 — FLAGS bit 0x0004: content is omitted from this transfer.
pub const uvfile_flag_omitted: u32 = 0x0004;

/// §7.3-r1/r2 — a `uvfile` card: unversioned-file sync state, carried
/// entirely outside the artifact model, its hash policy, and its
/// tag-state rules ([FossilArtifact] plays no part in this parse).
pub const UvfileCard = struct {
    name: []const u8, // §7.3-r3 — fossilize-escaped; NOT decoded here
    mtime: i64, // §7.3-r4 — decimal seconds since the Unix epoch
    hash: []const u8, // §7.3-r5/r16 — artifact-hash, or "-" for a deletion
    size: u32, // §7.3-r6 — payload byte count following the card line
    flags: u32, // §7.3-r7 — decimal bit field

    pub fn isDeletion(self: UvfileCard) bool {
        return self.flags & uvfile_flag_deletion != 0;
    }
    pub fn contentOmitted(self: UvfileCard) bool {
        return self.flags & uvfile_flag_omitted != 0;
    }
};

/// §7.3-r15 — the payload-free announcement form of a uvfile's state.
pub const UvigotCard = struct { name: []const u8, mtime: i64, hash: []const u8, size: u32 };

/// §7.3-r17 — a request for one named unversioned file.
pub const UvgimmeCard = struct { name: []const u8 };

/// §7.3-r2 — parse `uvfile NAME MTIME HASH SIZE FLAGS`. Arity 6 equals
/// `max_tokens`, so a 7th token silently drops from `tokens.items`
/// unless `surplus` is checked first (same shape as
/// `parseTimestampComment`'s `len != 5 or surplus` guard) — an
/// arity-only check here would wrongly accept an over-long line.
pub fn parseUvfileCard(tokens: Tokens) CardParseError!UvfileCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .uvfile);
    if (tokens.surplus) return CardParseError.Malformed; // §5.2 L1324, §7.3-r2
    if (tokens.len != 6) return CardParseError.Malformed;
    const mtime = std.fmt.parseInt(i64, tokens.items[2], 10) catch return CardParseError.Malformed;
    const size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed;
    const flags = std.fmt.parseInt(u32, tokens.items[5], 10) catch return CardParseError.Malformed;
    return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size, .flags = flags };
}

/// §7.3-r15 — parse `uvigot NAME MTIME HASH SIZE`. Arity 5 is below
/// `max_tokens` (6); `tokenizeCard` only ever sets `surplus` when
/// `len == max_tokens`, so a strict `len != 5` already excludes every
/// surplus line — no separate guard needed.
pub fn parseUvigotCard(tokens: Tokens) CardParseError!UvigotCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .uvigot);
    if (tokens.len != 5) return CardParseError.Malformed;
    const mtime = std.fmt.parseInt(i64, tokens.items[2], 10) catch return CardParseError.Malformed;
    const size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed;
    return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size };
}

/// §7.3-r17 — parse `uvgimme NAME`. Same below-max-tokens arity
/// argument as `parseUvigotCard`: `len != 2` alone excludes every
/// surplus line.
pub fn parseUvgimmeCard(tokens: Tokens) CardParseError!UvgimmeCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .uvgimme);
    if (tokens.len != 2) return CardParseError.Malformed;
    return .{ .name = tokens.items[1] };
}

/// §7.3-r9/r10/r12 — true iff a payload follows this card's LF: SIZE is
/// positive and neither the deletion nor the omitted-content flag is set.
pub fn uvfilePayloadFollows(card: UvfileCard) bool {
    const result = card.size > 0 and !card.isDeletion() and !card.contentOmitted();
    std.debug.assert(!result or (!card.isDeletion() and !card.contentOmitted() and card.size > 0));
    return result;
}

/// §7.3-r10/r11/r12 — frame the payload declared by `card`, or the
/// empty span immediately after `start` when none follows (r12). The
/// payload-present path delegates to `consumeContentPayload`'s
/// overflow-checked bound.
pub fn consumeUvfilePayload(body: []const u8, start: usize, card: UvfileCard) ContentFrameError!ContentPayload {
    std.debug.assert(start <= body.len);
    if (!uvfilePayloadFollows(card)) {
        const result: ContentPayload = .{ .bytes = body[start..start], .next = start };
        std.debug.assert(result.bytes.len == 0 and result.next == start);
        return result;
    }
    return consumeContentPayload(body, start, card.size);
}

/// §7.3-r13 — verify HASH against `content` before the caller stores
/// it. A garbage-length `hash` (neither 40 nor 64 hex) simply fails to
/// verify rather than panicking on an unrecognized algorithm.
pub fn verifyUvContent(hash: []const u8, content: []const u8) bool {
    const algo = artifact.nameAlgo(hash.len) orelse return false;
    var buf: [64]u8 = undefined;
    return std.mem.eql(u8, artifact.artifactName(algo, content, &buf), hash);
}

/// §7.3-r18 — the outcome of landing one incoming uvfile: accepted, a
/// write-permission rejection, or a no-op (identical, local wins, or
/// dry-run).
pub const UvLanding = enum { land, not_authorized, no_op };

/// §7.3-r18, §9.1.1-uv2 — recheck the same status function (`uvDecide`)
/// and the write-permission gate before landing an incoming uvfile.
/// `pull_only` is always passed `false`: it drives only `send_table`
/// (never read on the landing path), and `false` is its
/// least-restrictive setting, so it never masks the `.local` outcome
/// this function reads.
pub fn uvLandDecision(local: ?UvRow, card: UvfileCard, mode: UvMode, may_write: bool) UvLanding {
    const result: UvLanding = if (!may_write)
        .not_authorized // §7.3-r18
    else switch (uvDecide(local, card.mtime, card.hash, mode, false).local) {
        .fetch, .record_deletion, .touch_mtime => .land, // remote wins, or a permitted metadata-only update (r14)
        .none => .no_op, // identical, local wins, or dry-run: never overwrite
    };
    std.debug.assert((result == .not_authorized) == !may_write);
    return result;
}

/// §7.3-r19 — an accepted uvfile change clears the cached unversioned
/// catalog hash; a rejection or no-op leaves it untouched.
pub fn uvLandingClearsCatalog(outcome: UvLanding) bool {
    return outcome == .land;
}

test "sync §7.3: uvfile parse takes no hash-policy dependency" {
    // A HASH token that is neither 40 nor 64 lowercase-hex (not a legal
    // artifact-hash form) still parses: §7.3-r1 places uvfile outside
    // [FossilArtifact]'s hash-policy rules; only verifyUvContent (r13)
    // ever judges HASH against content.
    const card = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 NOT-A-HASH 0 4"));
    try std.testing.expectEqualStrings("NOT-A-HASH", card.hash);
}

test "sync §7.3: uvfile/uvigot/uvgimme card grammar fields" {
    // §7.3-r2: uvfile-card = "uvfile" SP fossilized SP decimal SP
    // uv-hash-field SP decimal SP decimal LF.
    const file = try parseUvfileCard(tokenizeCard("uvfile a.txt 1700000000 " ++ test_hash_a ++ " 12 0"));
    try std.testing.expectEqualStrings("a.txt", file.name);
    try std.testing.expectEqual(@as(i64, 1700000000), file.mtime);
    try std.testing.expectEqualStrings(test_hash_a, file.hash);
    try std.testing.expectEqual(@as(u32, 12), file.size);
    try std.testing.expectEqual(@as(u32, 0), file.flags);

    // uvigot-card = "uvigot" SP fossilized SP decimal SP uv-hash-field
    // SP decimal LF.
    const igot = try parseUvigotCard(tokenizeCard("uvigot a.txt 1700000000 " ++ test_hash_a ++ " 12"));
    try std.testing.expectEqualStrings("a.txt", igot.name);
    try std.testing.expectEqual(@as(i64, 1700000000), igot.mtime);
    try std.testing.expectEqualStrings(test_hash_a, igot.hash);
    try std.testing.expectEqual(@as(u32, 12), igot.size);

    // uvgimme-card = "uvgimme" SP fossilized LF.
    const gimme = try parseUvgimmeCard(tokenizeCard("uvgimme a.txt"));
    try std.testing.expectEqualStrings("a.txt", gimme.name);
}

test "sync §7.3: NAME is kept fossilize-escaped, not decoded" {
    // "a\sb" is the fossilize encoding of "a b" (§4.3, \s = SP); the
    // uvfile layer keeps the raw escaped token (r3) -- decoding is the
    // caller's job via artifact.decodeEscapes, not this parser's.
    const card = try parseUvfileCard(tokenizeCard("uvfile a\\sb 100 - 0 1"));
    try std.testing.expectEqualStrings("a\\sb", card.name);
}

test "sync §7.3: MTIME is decimal seconds since the epoch" {
    const card = try parseUvfileCard(tokenizeCard("uvfile a.txt 1700000000 - 0 1"));
    try std.testing.expectEqual(@as(i64, 1700000000), card.mtime);
}

test "sync §7.3: HASH is the content hash or literal -" {
    const with_hash = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 " ++ test_hash_a ++ " 0 0"));
    try std.testing.expectEqualStrings(test_hash_a, with_hash.hash);
    const deletion = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 - 0 1"));
    try std.testing.expectEqualStrings("-", deletion.hash);
}

test "sync §7.3: SIZE is the following payload byte count" {
    const body = "uvfile a.txt 100 " ++ test_hash_a ++ " 5 0\nhellotail";
    const line = try readLine(body, 0);
    const card = try parseUvfileCard(tokenizeCard(line.bytes));
    try std.testing.expectEqual(@as(u32, 5), card.size);
    const payload = try consumeUvfilePayload(body, line.next, card);
    try std.testing.expectEqualStrings("hello", payload.bytes);
}

test "sync §7.3: FLAGS bit 0x0001 deletion, 0x0004 omitted" {
    const del = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 - 0 1"));
    try std.testing.expect(del.isDeletion());
    try std.testing.expect(!del.contentOmitted());

    const omit = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 " ++ test_hash_a ++ " 42 4"));
    try std.testing.expect(!omit.isDeletion());
    try std.testing.expect(omit.contentOmitted());

    const both = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 - 0 5"));
    try std.testing.expect(both.isDeletion());
    try std.testing.expect(both.contentOmitted());
}

test "sync §7.3: deletion uses HASH -, SIZE 0, deletion flag" {
    const card = try parseUvfileCard(tokenizeCard("uvfile f 100 - 0 1"));
    try std.testing.expectEqualStrings("-", card.hash);
    try std.testing.expectEqual(@as(u32, 0), card.size);
    try std.testing.expect(card.isDeletion());
    try std.testing.expect(!uvfilePayloadFollows(card));
}

test "sync §7.3: omitted-content transfer carries no payload despite SIZE" {
    const card = try parseUvfileCard(tokenizeCard("uvfile f 100 " ++ test_hash_a ++ " 42 4"));
    try std.testing.expect(!uvfilePayloadFollows(card));
    const body = "uvfile f 100 " ++ test_hash_a ++ " 42 4\nnext line";
    const line = try readLine(body, 0);
    const payload = try consumeUvfilePayload(body, line.next, card);
    try std.testing.expectEqual(@as(usize, 0), payload.bytes.len);
    try std.testing.expectEqual(line.next, payload.next);
}

test "sync §7.3: SIZE>0 no flags yields exactly SIZE payload bytes" {
    const body = "uvfile f 100 " ++ test_hash_a ++ " 5 0\nhelloXX";
    const line = try readLine(body, 0);
    const card = try parseUvfileCard(tokenizeCard(line.bytes));
    try std.testing.expect(uvfilePayloadFollows(card));
    const payload = try consumeUvfilePayload(body, line.next, card);
    try std.testing.expectEqualStrings("hello", payload.bytes);
}

test "sync §7.3: byte after payload begins the next card line" {
    const body = "uvfile f 100 " ++ test_hash_a ++ " 5 0\nhellouvgimme x";
    const line = try readLine(body, 0);
    const card = try parseUvfileCard(tokenizeCard(line.bytes));
    const payload = try consumeUvfilePayload(body, line.next, card);
    try std.testing.expectEqual(line.next + 5, payload.next);
    try std.testing.expectEqualStrings("uvgimme x", body[payload.next..]);
}

test "sync §7.3: omitted content leaves next card immediately after LF" {
    const body = "uvfile f 100 - 0 5\nuvgimme x";
    const line = try readLine(body, 0);
    const card = try parseUvfileCard(tokenizeCard(line.bytes));
    const payload = try consumeUvfilePayload(body, line.next, card);
    try std.testing.expectEqual(@as(usize, 0), payload.bytes.len);
    try std.testing.expectEqual(line.next, payload.next);
    try std.testing.expectEqualStrings("uvgimme x", body[payload.next..]);
}

test "sync §7.3: HASH is verified against bytes before storing (rejection)" {
    // RFC 3174 §7.3 test vector: SHA-1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d.
    const sha1_abc = "a9993e364706816aba3e25717850c26c9cd0d89d";
    try std.testing.expect(verifyUvContent(sha1_abc, "abc"));
    try std.testing.expect(!verifyUvContent(sha1_abc, "wrong content"));
    try std.testing.expect(!verifyUvContent("not-a-legal-length-hash", "abc"));
}

test "sync §7.3: omitted card updates metadata only when status permits" {
    // §9.1.1 status 2 (older local, same hash): a metadata-only touch
    // is permitted from an omitted-content card, since it never claims
    // to carry file bytes (r14).
    const local: UvRow = .{ .name = "f", .mtime = 100, .hash = "same" };
    const newer_card: UvfileCard = .{ .name = "f", .mtime = 200, .hash = "same", .size = 42, .flags = uvfile_flag_omitted };
    try std.testing.expectEqual(UvLanding.land, uvLandDecision(local, newer_card, .{}, true));

    // Identical state: no update even though write-permitted.
    const identical_card: UvfileCard = .{ .name = "f", .mtime = 100, .hash = "same", .size = 42, .flags = uvfile_flag_omitted };
    try std.testing.expectEqual(UvLanding.no_op, uvLandDecision(local, identical_card, .{}, true));
}

test "sync §7.3: uvigot is the payload-free announcement form" {
    const card = try parseUvigotCard(tokenizeCard("uvigot f 100 " ++ test_hash_a ++ " 12"));
    try std.testing.expectEqualStrings("f", card.name);
    try std.testing.expectEqual(@as(i64, 100), card.mtime);
    try std.testing.expectEqualStrings(test_hash_a, card.hash);
    try std.testing.expectEqual(@as(u32, 12), card.size);
}

test "sync §7.3: a deletion is announced or transferred with HASH -" {
    const igot = try parseUvigotCard(tokenizeCard("uvigot f 100 - 0"));
    try std.testing.expectEqualStrings("-", igot.hash);
    const file = try parseUvfileCard(tokenizeCard("uvfile f 100 - 0 1"));
    try std.testing.expectEqualStrings("-", file.hash);
}

test "sync §7.3: uvgimme NAME requests a named unversioned file" {
    const card = try parseUvgimmeCard(tokenizeCard("uvgimme p/q%20r"));
    try std.testing.expectEqualStrings("p/q%20r", card.name);
}

test "sync §7.3: only a write-permitted receiver may land a uvfile (rejection)" {
    const card: UvfileCard = .{ .name = "f", .mtime = 200, .hash = "new", .size = 3, .flags = 0 };
    try std.testing.expectEqual(UvLanding.not_authorized, uvLandDecision(null, card, .{}, false));
    try std.testing.expectEqual(UvLanding.land, uvLandDecision(null, card, .{}, true));
}

test "sync §7.3: an accepted change clears the cached catalog hash" {
    try std.testing.expect(uvLandingClearsCatalog(.land));
    try std.testing.expect(!uvLandingClearsCatalog(.no_op));
    try std.testing.expect(!uvLandingClearsCatalog(.not_authorized));
}

test "sync §7.3: under-arity uvfile card is malformed" {
    try std.testing.expectError(CardParseError.Malformed, parseUvfileCard(tokenizeCard("uvfile f 100 - 0")));
}

test "sync §5.2/§7.3: over-arity uvfile card is malformed" {
    // §5.2 L1324, §7.3-r2 — the blocking case: arity (6) equals
    // max_tokens (6), so a 7th token drops silently unless `surplus`
    // is checked ahead of the length check.
    const tokens = tokenizeCard("uvfile n 1 - 0 0 x");
    try std.testing.expectEqual(@as(usize, max_tokens), tokens.len);
    try std.testing.expect(tokens.surplus);
    try std.testing.expectError(CardParseError.Malformed, parseUvfileCard(tokens));
}

test "sync §7.3: non-numeric MTIME/SIZE/FLAGS is malformed" {
    const cases = [_][]const u8{
        "uvfile f x - 0 0", // non-numeric MTIME
        "uvfile f 100 - x 0", // non-numeric SIZE
        "uvfile f 100 - 0 x", // non-numeric FLAGS
    };
    for (cases) |line| {
        try std.testing.expectError(CardParseError.Malformed, parseUvfileCard(tokenizeCard(line)));
    }
}

test "sync §7.3: uvigot wrong arity is malformed" {
    try std.testing.expectError(CardParseError.Malformed, parseUvigotCard(tokenizeCard("uvigot f 100 -")));
    try std.testing.expectError(CardParseError.Malformed, parseUvigotCard(tokenizeCard("uvigot f 100 - 0 extra")));
}

test "sync §7.3: uvgimme wrong arity is malformed" {
    try std.testing.expectError(CardParseError.Malformed, parseUvgimmeCard(tokenizeCard("uvgimme")));
    try std.testing.expectError(CardParseError.Malformed, parseUvgimmeCard(tokenizeCard("uvgimme f extra")));
}

test "sync §7.3 fuzz: parseUvfileCard/parseUvigotCard/parseUvgimmeCard never crash on adversarial tokens" {
    // Untrusted wire tokens: the three uvfile-family parsers must never
    // crash, only return a declared error or a card whose derived
    // payload-framing postcondition (r9/r10/r12) holds.
    var prng = std.Random.DefaultPrng.init(0x0F11E_ABCD);
    const rand = prng.random();
    const max_field: usize = 24;
    var field_buf: [max_tokens][max_field]u8 = undefined;
    var round: usize = 0;
    while (round < 500) : (round += 1) {
        var tokens: Tokens = .{};
        const n = 1 + rand.uintLessThan(usize, max_tokens);
        tokens.len = n;
        var i: usize = 1;
        while (i < n) : (i += 1) {
            const len = rand.uintLessThan(usize, max_field + 1);
            for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
            tokens.items[i] = field_buf[i][0..len];
        }

        tokens.items[0] = "uvfile";
        if (parseUvfileCard(tokens)) |card| {
            const follows = uvfilePayloadFollows(card);
            try std.testing.expect(!follows or (!card.isDeletion() and !card.contentOmitted() and card.size > 0));
        } else |_| {}

        tokens.items[0] = "uvigot";
        if (parseUvigotCard(tokens)) |_| {} else |_| {}

        tokens.items[0] = "uvgimme";
        if (parseUvgimmeCard(tokens)) |_| {} else |_| {}
    }
}

// ---------------------------------------------------------------------------
// Section 9.1.1 — Unversioned-File Synchronisation
// ---------------------------------------------------------------------------

/// §9.1.1 L2064-2067 — a local unversioned-file row as classification sees
/// it. A null hash marks a locally recorded deletion. `size`/`encoding`/
/// `content` belong to the storage layer and play no part in status or
/// catalog-hash math, so they are not carried here.
pub const UvRow = struct {
    name: []const u8,
    mtime: i64,
    hash: ?[]const u8,
};

/// §9.1.1 L2095-2113 — the five-way (0..5) unversioned status code.
pub const UvStatus = enum(u3) {
    absent = 0,
    remote_wins = 1,
    older = 2,
    identical = 3,
    newer = 4,
    local_wins = 5,
};

/// §9.1.1 L2133-2154 — the local-store effect of an (already revert-mapped)
/// status. `fetch` queues uvgimme and drops the row that would be
/// replaced; `record_deletion` stores the received mtime and clears
/// hash/size/encoding/content; `touch_mtime` updates local mtime only.
pub const UvLocalAction = enum { none, fetch, record_deletion, touch_mtime };

/// §9.1.1 L2156-2160 — the send-table effect of an (already revert-mapped)
/// status.
pub const SendTableOp = enum { remove, mtime_only, keep_full };

/// §9.1.1 L2115-2119 — revert/dry-run posture for one uvigot classification.
pub const UvMode = struct {
    revert: bool = false,
    dry_run: bool = false,
};

/// §9.1.1 — the combined outcome of classifying one uvigot row: the
/// post-revert-map status plus both of its effects, computed together so
/// the revert map is applied exactly once (never for one effect but not
/// the other).
pub const UvDecision = struct {
    status: UvStatus,
    local: UvLocalAction,
    send_table: SendTableOp,
};

/// §9.1.1 L2083-2086 — the per-request catalog-emission dedup latch; one
/// instance covers one request.
pub const ServerUvRequest = struct {
    catalog_emitted: bool = false,
};

/// §9.1.1 L2095-2113 steps 1-5 — the raw status code, before revert
/// mapping. `remote_hash` is the wire HASH (40/64 hex, or "-" for a
/// remote deletion).
pub fn uvStatus(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8) UvStatus {
    std.debug.assert(remote_hash.len >= 1 and remote_hash.len <= 64);
    const l = local orelse return .absent;
    if (l.hash) |h| std.debug.assert(h.len <= 64);
    const local_hash: []const u8 = l.hash orelse "-";
    const mtime_cmp = std.math.order(l.mtime, remote_mtime);
    const hash_cmp = std.mem.order(u8, local_hash, remote_hash);
    const result: UvStatus = if (hash_cmp == .eq)
        switch (mtime_cmp) {
            .lt => .older,
            .eq => .identical,
            .gt => .newer,
        }
    else if (mtime_cmp == .lt or (mtime_cmp == .eq and hash_cmp == .lt))
        .remote_wins
    else
        .local_wins;
    std.debug.assert(@intFromEnum(result) <= 5);
    return result;
}

/// §9.1.1 L2115-2117 — revert mode remaps 4->2 and 5->1; every other
/// status is unchanged.
fn revertMap(s: UvStatus) UvStatus {
    return switch (s) {
        .newer => .older,
        .local_wins => .remote_wins,
        else => s,
    };
}

/// §9.1.1 L2156-2160 — the send-table effect of an already-mapped status;
/// a pull-only session never keeps an upload queued.
fn sendTableOp(status: UvStatus, pull_only: bool) SendTableOp {
    const result: SendTableOp = switch (status) {
        .absent, .remote_wins, .older, .identical => .remove,
        .newer => if (pull_only) .remove else .mtime_only,
        .local_wins => if (pull_only) .remove else .keep_full,
    };
    std.debug.assert(!pull_only or (result != .mtime_only and result != .keep_full));
    return result;
}

/// §9.1.1 L2133-2154, L2118-2119 — the local-store effect of an
/// already-mapped status. `remote_is_deletion` is HASH == "-" on the wire;
/// dry-run suppresses every local change.
fn uvLocalAction(status: UvStatus, remote_is_deletion: bool, dry_run: bool) UvLocalAction {
    if (dry_run) return .none;
    const result: UvLocalAction = switch (status) {
        .absent => if (remote_is_deletion) .none else .fetch,
        .remote_wins => if (remote_is_deletion) .record_deletion else .fetch,
        .older => .touch_mtime,
        .identical, .newer, .local_wins => .none,
    };
    std.debug.assert(!dry_run or result == .none);
    return result;
}

/// §9.1.1 — the deep composed interface: raw status, revert-mapped at most
/// once, then both its local-store and send-table effects computed over
/// the mapped value.
pub fn uvDecide(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8, mode: UvMode, pull_only: bool) UvDecision {
    const raw = uvStatus(local, remote_mtime, remote_hash);
    const status = if (mode.revert) revertMap(raw) else raw;
    const remote_is_deletion = std.mem.eql(u8, remote_hash, "-");
    const decision: UvDecision = .{
        .status = status,
        .local = uvLocalAction(status, remote_is_deletion, mode.dry_run),
        .send_table = sendTableOp(status, pull_only),
    };
    std.debug.assert(!mode.dry_run or decision.local == .none);
    std.debug.assert(@intFromEnum(decision.status) <= 5);
    return decision;
}

/// §9.1.1 L2061-2063 — pure civil-from-unix conversion (UTC, no leap
/// seconds), matching SQLite's `datetime(mtime,'unixepoch')` output
/// exactly: fixed-width `YYYY-MM-DD HH:MM:SS`. Civil-date math is
/// Hinnant's `civil_from_days` (proleptic Gregorian, floor division
/// throughout so negative `mtime` stays correct) — never `std.time`, so
/// the result is a pure function of `mtime`.
fn formatUvDatetime(mtime: i64, buf: *[19]u8) []const u8 {
    const secs_per_day: i64 = 86400;
    const days = @divFloor(mtime, secs_per_day);
    const secs_of_day = @mod(mtime, secs_per_day);
    std.debug.assert(secs_of_day >= 0 and secs_of_day < secs_per_day);

    // Hinnant civil_from_days: days since 1970-01-01 -> proleptic Gregorian
    // y/m/d (http://howardhinnant.github.io/date_algorithms.html). era/doe
    // split uses floor division so doe always lands in [0, 146096],
    // keeping every later division non-negative regardless of sign(days).
    const z = days + 719468;
    const era = @divFloor(z, 146097);
    const doe = z - era * 146097; // [0, 146096]
    const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365); // [0, 399]
    const y = yoe + era * 400;
    const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); // [0, 365]
    const mp = @divTrunc(5 * doy + 2, 153); // [0, 11]
    const d = doy - @divTrunc(153 * mp + 2, 5) + 1; // [1, 31]
    const m = mp + (if (mp < 10) @as(i64, 3) else @as(i64, -9)); // [1, 12]
    const year = y + (if (m <= 2) @as(i64, 1) else @as(i64, 0));

    std.debug.assert(year >= 0 and year <= 9999);
    std.debug.assert(m >= 1 and m <= 12);
    std.debug.assert(d >= 1 and d <= 31);

    const hours = @divTrunc(secs_of_day, 3600);
    const minutes = @divTrunc(@mod(secs_of_day, 3600), 60);
    const seconds = @mod(secs_of_day, 60);

    // Written field-by-field (no std.fmt.bufPrint) so there is no fallible
    // path at all — width is fixed and every value is pre-bounded above.
    writeFixedDigits(buf[0..4], @intCast(year));
    buf[4] = '-';
    writeFixedDigits(buf[5..7], @intCast(m));
    buf[7] = '-';
    writeFixedDigits(buf[8..10], @intCast(d));
    buf[10] = ' ';
    writeFixedDigits(buf[11..13], @intCast(hours));
    buf[13] = ':';
    writeFixedDigits(buf[14..16], @intCast(minutes));
    buf[16] = ':';
    writeFixedDigits(buf[17..19], @intCast(seconds));
    return buf[0..19];
}

/// Writes `value` as `buf.len` zero-padded ASCII digits, most significant
/// first. `value` must fit in `buf.len` digits (asserted, not truncated).
fn writeFixedDigits(buf: []u8, value: u32) void {
    var v = value;
    for (0..buf.len) |k| {
        const i = buf.len - 1 - k;
        buf[i] = '0' + @as(u8, @intCast(v % 10));
        v /= 10;
    }
    std.debug.assert(v == 0);
}

/// §9.1.1 L2061-2067 — the unversioned catalog hash: SHA-1 [RFC3174] over,
/// in name order, `NAME " " DATETIME " " HASH "\n"` for every row whose
/// hash is not null (a deleted row is excluded, L2064-2067). `rows` must
/// already be name-sorted — the store owns that ordering, not re-derived
/// here.
pub fn uvCatalogHash(rows: []const UvRow) HashHex {
    var hasher = std.crypto.hash.Sha1.init(.{});
    var prev: ?[]const u8 = null;
    for (rows) |r| {
        std.debug.assert(r.name.len <= max_line_len - 1);
        if (prev) |p| std.debug.assert(std.mem.order(u8, p, r.name) != .gt);
        prev = r.name;
        const h = r.hash orelse continue;
        std.debug.assert(h.len <= 64);
        var dt_buf: [19]u8 = undefined;
        const dt = formatUvDatetime(r.mtime, &dt_buf);
        hasher.update(r.name);
        hasher.update(" ");
        hasher.update(dt);
        hasher.update(" ");
        hasher.update(h);
        hasher.update("\n");
    }
    var digest: [20]u8 = undefined;
    hasher.final(&digest);
    const hex = sha1FinalHex(digest);
    for (hex) |c| std.debug.assert((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'));
    return hex;
}

/// §9.1.1 L2083-2086 — wraps `serverUvResponse` with the per-request
/// duplicate-catalog latch: once a catalog has gone out this request, a
/// later call on the same `state` yields `.none` regardless of inputs.
pub fn serverUvCatalog(state: *ServerUvRequest, match: bool, has_read: bool, may_upload: bool) UvResponse {
    const already = state.catalog_emitted;
    const result: UvResponse = if (already) .none else serverUvResponse(match, has_read, may_upload);
    state.catalog_emitted = already or (result != .none);
    std.debug.assert(!already or result == .none);
    return result;
}

/// §3.1 L374-399, §9.1.1 L2054-2056 — the client's per-session unversioned
/// send table: NAME -> mtimeOnly. Owns duped NAME keys, so entries outlive
/// the per-round wire buffers that named them (mirrors `RoundTables`).
pub const UvSendTable = struct {
    map: std.StringHashMapUnmanaged(bool) = .empty,

    /// §9.1.1 L2054-2056 — session start: every local row whose hash is
    /// not null seeds the table with mtimeOnly false.
    pub fn initFromLocal(gpa: std.mem.Allocator, rows: []const UvRow) error{OutOfMemory}!UvSendTable {
        var self: UvSendTable = .{};
        errdefer self.deinit(gpa);
        try self.map.ensureUnusedCapacity(gpa, @intCast(rows.len));
        for (rows) |r| {
            if (r.hash == null) continue;
            std.debug.assert(r.name.len <= max_line_len - 1);
            if (self.map.contains(r.name)) continue;
            const owned = try gpa.dupe(u8, r.name);
            self.map.putAssumeCapacity(owned, false);
        }
        return self;
    }

    pub fn deinit(self: *UvSendTable, gpa: std.mem.Allocator) void {
        var it = self.map.keyIterator();
        while (it.next()) |key| gpa.free(key.*);
        self.map.deinit(gpa);
        self.* = undefined;
    }

    /// §9.1.1 L2156-2160 — apply one status's send-table effect for NAME.
    pub fn apply(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, op: SendTableOp) error{OutOfMemory}!void {
        std.debug.assert(name.len <= max_line_len - 1);
        switch (op) {
            .remove => if (self.map.fetchRemove(name)) |kv| gpa.free(kv.key),
            .mtime_only => try self.put(gpa, name, true),
            .keep_full => try self.put(gpa, name, false),
        }
        std.debug.assert(self.contains(name) == (op != .remove));
    }

    fn put(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, mtime_only: bool) error{OutOfMemory}!void {
        if (self.map.getPtr(name)) |v| {
            v.* = mtime_only;
            return;
        }
        try self.map.ensureUnusedCapacity(gpa, 1);
        const owned = try gpa.dupe(u8, name);
        self.map.putAssumeCapacity(owned, mtime_only);
    }

    pub fn contains(self: *const UvSendTable, name: []const u8) bool {
        return self.map.contains(name);
    }

    pub fn mtimeOnly(self: *const UvSendTable, name: []const u8) ?bool {
        return self.map.get(name);
    }

    pub fn isEmpty(self: *const UvSendTable) bool {
        return self.map.count() == 0;
    }

    pub fn count(self: *const UvSendTable) usize {
        return self.map.count();
    }
};

test "sync §9.1.1: send table init loads non-null-hash rows, mtimeOnly false, catalog-sent false" {
    const rows = [_]UvRow{
        .{ .name = "a.txt", .mtime = 1, .hash = "h1" },
        .{ .name = "b.txt", .mtime = 2, .hash = null },
    };
    var table = try UvSendTable.initFromLocal(std.testing.allocator, &rows);
    defer table.deinit(std.testing.allocator);
    try std.testing.expectEqual(@as(usize, 1), table.count());
    try std.testing.expectEqual(@as(?bool, false), table.mtimeOnly("a.txt"));
    try std.testing.expect(!table.contains("b.txt"));

    const s: UvSession = .{};
    try std.testing.expectEqual(false, s.hash_sent);
}

test "sync §9.1.1: catalog hash of an empty catalog is SHA-1(\"\")" {
    const hash = uvCatalogHash(&.{});
    try std.testing.expectEqualStrings("da39a3ee5e6b4b0d3255bfef95601890afd80709", &hash);
}

test "sync §9.1.1: uv-hash datetime segment is SQLite datetime(mtime,'unixepoch')" {
    var buf1: [19]u8 = undefined;
    try std.testing.expectEqualStrings("1970-01-01 00:00:00", formatUvDatetime(0, &buf1));
    var buf2: [19]u8 = undefined;
    try std.testing.expectEqualStrings("2009-02-13 23:31:30", formatUvDatetime(1234567890, &buf2));
}

test "sync §9.1.1: null-hash and deleted rows are excluded from the catalog hash" {
    const deleted_only = [_]UvRow{
        .{ .name = "a.txt", .mtime = 1, .hash = null },
        .{ .name = "b.txt", .mtime = 2, .hash = null },
    };
    const h1 = uvCatalogHash(&deleted_only);
    try std.testing.expectEqualStrings("da39a3ee5e6b4b0d3255bfef95601890afd80709", &h1);

    const with_one_live = [_]UvRow{
        .{ .name = "a.txt", .mtime = 1, .hash = null },
        .{ .name = "b.txt", .mtime = 2, .hash = "h2" },
    };
    const h2 = uvCatalogHash(&with_one_live);
    try std.testing.expect(!std.mem.eql(u8, &h1, &h2));
}

test "sync §9.1.1: server suppresses duplicate catalog emission within one request" {
    var s: ServerUvRequest = .{};
    try std.testing.expectEqual(UvResponse.push_ok, serverUvCatalog(&s, false, true, true));
    try std.testing.expectEqual(UvResponse.none, serverUvCatalog(&s, false, true, true));

    var s2: ServerUvRequest = .{};
    try std.testing.expectEqual(UvResponse.push_ok, serverUvCatalog(&s2, false, true, true));
}

test "sync §9.1.1: status 0 when no local row exists" {
    try std.testing.expectEqual(UvStatus.absent, uvStatus(null, 100, "abc"));
}

test "sync §9.1.1: LOCAL-HASH is - for a null local hash; HASH-CMP compares it bytewise" {
    const row: UvRow = .{ .name = "a", .mtime = 100, .hash = null };
    try std.testing.expectEqual(UvStatus.identical, uvStatus(row, 100, "-"));
    try std.testing.expectEqual(UvStatus.remote_wins, uvStatus(row, 100, "abc"));
}

test "sync §9.1.1: HASH-CMP 0 yields status 3 + MTIME-CMP" {
    const Case = struct { local_mtime: i64, remote_mtime: i64, want: UvStatus };
    const cases = [_]Case{
        .{ .local_mtime = 100, .remote_mtime = 200, .want = .older },
        .{ .local_mtime = 150, .remote_mtime = 150, .want = .identical },
        .{ .local_mtime = 300, .remote_mtime = 200, .want = .newer },
    };
    for (cases) |c| {
        const row: UvRow = .{ .name = "a", .mtime = c.local_mtime, .hash = "same-hash" };
        try std.testing.expectEqual(c.want, uvStatus(row, c.remote_mtime, "same-hash"));
    }
}

test "sync §9.1.1: status 1 when MTIME-CMP<0, or MTIME-CMP 0 and HASH-CMP<0" {
    const older: UvRow = .{ .name = "a", .mtime = 100, .hash = "aaa" };
    try std.testing.expectEqual(UvStatus.remote_wins, uvStatus(older, 200, "zzz"));
    const tie: UvRow = .{ .name = "a", .mtime = 100, .hash = "aaa" };
    try std.testing.expectEqual(UvStatus.remote_wins, uvStatus(tie, 100, "zzz"));
}

test "sync §9.1.1: status 5 otherwise (local content or deletion overrides)" {
    const newer: UvRow = .{ .name = "a", .mtime = 300, .hash = "zzz" };
    try std.testing.expectEqual(UvStatus.local_wins, uvStatus(newer, 200, "aaa"));
    const tie: UvRow = .{ .name = "a", .mtime = 100, .hash = "zzz" };
    try std.testing.expectEqual(UvStatus.local_wins, uvStatus(tie, 100, "aaa"));
}

test "sync §9.1.1: revert mode maps status 4 to 2 and status 5 to 1" {
    const newer: UvRow = .{ .name = "a", .mtime = 300, .hash = "same" };
    const d4 = uvDecide(newer, 200, "same", .{ .revert = true }, false);
    try std.testing.expectEqual(UvStatus.older, d4.status);

    const local_wins: UvRow = .{ .name = "a", .mtime = 300, .hash = "zzz" };
    const d5 = uvDecide(local_wins, 200, "aaa", .{ .revert = true }, false);
    try std.testing.expectEqual(UvStatus.remote_wins, d5.status);
}

test "sync §9.1.1: dry-run suppresses local changes and reply messages" {
    const absent = uvDecide(null, 100, "abc", .{ .dry_run = true }, false);
    try std.testing.expectEqual(UvLocalAction.none, absent.local);

    const remote_row: UvRow = .{ .name = "a", .mtime = 100, .hash = "aaa" };
    const wins = uvDecide(remote_row, 200, "zzz", .{ .dry_run = true }, false);
    try std.testing.expectEqual(UvLocalAction.none, wins.local);

    const older_row: UvRow = .{ .name = "a", .mtime = 100, .hash = "same" };
    const o = uvDecide(older_row, 200, "same", .{ .dry_run = true }, false);
    try std.testing.expectEqual(UvLocalAction.none, o.local);
}

test "sync §9.1.1: status 0 fetches (uvgimme + drop replaced) unless HASH is -" {
    try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.absent, false, false));
    try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.absent, true, false));
}

test "sync §9.1.1: status 1 fetches when HASH present, records deletion when -" {
    try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.remote_wins, false, false));
    try std.testing.expectEqual(UvLocalAction.record_deletion, uvLocalAction(.remote_wins, true, false));
}

test "sync §9.1.1: status 2 updates local mtime only" {
    try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, false, false));
    try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, true, false));
}

test "sync §9.1.1: status 3 makes no local change" {
    try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, false, false));
    try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, true, false));
}

test "sync §9.1.1: status 4 keeps send-table entry mtimeOnly true when uploads allowed" {
    try std.testing.expectEqual(SendTableOp.mtime_only, sendTableOp(.newer, false));
}

test "sync §9.1.1: status 5 keeps/reinserts send-table entry mtimeOnly false when uploads allowed" {
    try std.testing.expectEqual(SendTableOp.keep_full, sendTableOp(.local_wins, false));
}

test "sync §9.1.1: send-table update — statuses 0-3 remove, 4 mtimeOnly, 5 keeps full" {
    const Case = struct { status: UvStatus, want: SendTableOp };
    const cases = [_]Case{
        .{ .status = .absent, .want = .remove },
        .{ .status = .remote_wins, .want = .remove },
        .{ .status = .older, .want = .remove },
        .{ .status = .identical, .want = .remove },
        .{ .status = .newer, .want = .mtime_only },
        .{ .status = .local_wins, .want = .keep_full },
    };
    for (cases) |c| try std.testing.expectEqual(c.want, sendTableOp(c.status, false));
}

test "sync §9.1.1: pull-only removes statuses 4 and 5 from the send table" {
    try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.newer, true));
    try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.local_wins, true));
}

test "sync §9.1.1: send-table apply removes, or sets mtimeOnly true/false" {
    var table: UvSendTable = .{};
    defer table.deinit(std.testing.allocator);
    try table.apply(std.testing.allocator, "a.txt", .mtime_only);
    try std.testing.expectEqual(@as(?bool, true), table.mtimeOnly("a.txt"));
    try table.apply(std.testing.allocator, "a.txt", .keep_full);
    try std.testing.expectEqual(@as(?bool, false), table.mtimeOnly("a.txt"));
    try table.apply(std.testing.allocator, "a.txt", .remove);
    try std.testing.expect(!table.contains("a.txt"));
    try std.testing.expect(table.isEmpty());
}

fn uvSendTableNoLeak(gpa: std.mem.Allocator) !void {
    const rows = [_]UvRow{
        .{ .name = "a.txt", .mtime = 1, .hash = "h1" },
        .{ .name = "b.txt", .mtime = 2, .hash = null },
    };
    var table = try UvSendTable.initFromLocal(gpa, &rows);
    defer table.deinit(gpa);
    try table.apply(gpa, "a.txt", .mtime_only);
    try table.apply(gpa, "c.txt", .keep_full);
    try table.apply(gpa, "a.txt", .remove);
}

test "sync §9.1.1: send table init and apply survive allocation failure" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, uvSendTableNoLeak, .{});
}

/// §9.1.1-uv1 — the receiver's answer to a uvgimme: uvfile carrying
/// content when it fits the send-size budget, or uvigot re-announcing
/// the same state when it does not. The budget check itself (§4 body
/// cap) lives in the response assembler; this takes its verdict.
pub const UvGimmeReply = enum { uvfile, uvigot };

/// §9.1.1-uv1, §7.3 — "The receiver answers with uvfile ... or with
/// uvigot ... again if the send-size limit prevents including the
/// content in that response."
pub fn uvGimmeReply(content_fits: bool) UvGimmeReply {
    return if (content_fits) .uvfile else .uvigot;
}

test "sync §9.1.1: uvgimme answered uvfile with content else uvigot" {
    try std.testing.expectEqual(UvGimmeReply.uvfile, uvGimmeReply(true));
    try std.testing.expectEqual(UvGimmeReply.uvigot, uvGimmeReply(false));
}

test "sync §9.1.1: uvfile landing rechecks the same status function" {
    // remote wins (status 0, absent locally) + write-permitted -> land.
    const absent_card: UvfileCard = .{ .name = "f", .mtime = 100, .hash = test_hash_a, .size = 3, .flags = 0 };
    try std.testing.expectEqual(UvLanding.land, uvLandDecision(null, absent_card, .{}, true));

    // local wins (status 5) -> no_op even though write-permitted.
    const local: UvRow = .{ .name = "f", .mtime = 300, .hash = "zzz" };
    const local_wins_card: UvfileCard = .{ .name = "f", .mtime = 200, .hash = "aaa", .size = 3, .flags = 0 };
    try std.testing.expectEqual(UvLanding.no_op, uvLandDecision(local, local_wins_card, .{}, true));

    // write not permitted -> not_authorized regardless of status.
    try std.testing.expectEqual(UvLanding.not_authorized, uvLandDecision(null, absent_card, .{}, false));
}