//! Unit tests for the sync protocol (draft-fossil-sync-protocol-00).
//!
//! Separated from the implementation per the kind-separation convention
//! (mirrors the sibling `*_fuzz.zig` split). Every test here exercises only
//! `sync`'s public surface; tests reaching file-private decls stay inline in
//! `sync.zig`.
const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const delta = @import("delta.zig");
const sync = @import("sync.zig");
const Admission = sync.Admission;
const Capability = sync.Capability;
const CapabilityDecision = sync.CapabilityDecision;
const CapabilitySet = sync.CapabilitySet;
const CardParseError = sync.CardParseError;
const CiLockResult = sync.CiLockResult;
const Client = sync.Client;
const ClientErrorAction = sync.ClientErrorAction;
const ClientState = sync.ClientState;
const CompressLevel = sync.CompressLevel;
const ConfigError = sync.ConfigError;
const ConfigFrameError = sync.ConfigFrameError;
const ConfigKind = sync.ConfigKind;
const ConfigPushDecision = sync.ConfigPushDecision;
const ContentFrameError = sync.ContentFrameError;
const CookieStore = sync.CookieStore;
const Credential = sync.Credential;
const DateTime = sync.DateTime;
const DeltaBasisSend = sync.DeltaBasisSend;
const Disposition = sync.Disposition;
const Eligibility = sync.Eligibility;
const ErrorReset = sync.ErrorReset;
const ErrorSite = sync.ErrorSite;
const FatalCause = sync.FatalCause;
const GateDecision = sync.GateDecision;
const GimmeResponse = sync.GimmeResponse;
const IgotDisposition = sync.IgotDisposition;
const IntegritySchedule = sync.IntegritySchedule;
const LineError = sync.LineError;
const Identity = sync.Identity;
const LoginCard = sync.LoginCard;
const LoginResult = sync.LoginResult;
const LoginTransport = sync.LoginTransport;
const MediaType = sync.MediaType;
const MessageAction = sync.MessageAction;
const MethodDisposition = sync.MethodDisposition;
const NextRequest = sync.NextRequest;
const Operation = sync.Operation;
const Operator = sync.Operator;
const Peer = sync.Peer;
const PendingDeltaQueue = sync.PendingDeltaQueue;
const PragmaDirection = sync.PragmaDirection;
const PragmaError = sync.PragmaError;
const PragmaName = sync.PragmaName;
const PragmaSchedule = sync.PragmaSchedule;
const PrivateIgotAction = sync.PrivateIgotAction;
const PrivateReceipt = sync.PrivateReceipt;
const PushPull = sync.PushPull;
const PushPullCard = sync.PushPullCard;
const QuoteError = sync.QuoteError;
const ReplyBodyStep = sync.ReplyBodyStep;
const ReplyFraming = sync.ReplyFraming;
const ReqconfigDisposition = sync.ReqconfigDisposition;
const RequestCard = sync.RequestCard;
const RequestOrder = sync.RequestOrder;
const RoundObservation = sync.RoundObservation;
const RoundTables = sync.RoundTables;
const SendPrivateDecision = sync.SendPrivateDecision;
const ServerUvRequest = sync.ServerUvRequest;
const Side = sync.Side;
const SignalKind = sync.SignalKind;
const SyncProjectCode = sync.SyncProjectCode;
const TimestampComment = sync.TimestampComment;
const Tokens = sync.Tokens;
const Transport = sync.Transport;
const UvGimmeReply = sync.UvGimmeReply;
const UvLanding = sync.UvLanding;
const UvLocalAction = sync.UvLocalAction;
const UvResponse = sync.UvResponse;
const UvRow = sync.UvRow;
const UvSendTable = sync.UvSendTable;
const UvSession = sync.UvSession;
const UvStatus = sync.UvStatus;
const UvUpload = sync.UvUpload;
const UvfileCard = sync.UvfileCard;
const VersionInfo = sync.VersionInfo;
const admitInbound = sync.admitInbound;
const capabilityFromLetter = sync.capabilityFromLetter;
const capabilitySetFromLetters = sync.capabilitySetFromLetters;
const causeIsFatal = sync.causeIsFatal;
const checkRedirectDowngrade = sync.checkRedirectDowngrade;
const checkRequestOrder = sync.checkRequestOrder;
const classifyCard = sync.classifyCard;
const classifyPragma = sync.classifyPragma;
const clientMaySendUvHash = sync.clientMaySendUvHash;
const clientOnErrorCard = sync.clientOnErrorCard;
const clientOnMessageCard = sync.clientOnMessageCard;
const clientOnUvPullOnly = sync.clientOnUvPullOnly;
const clientOnUvPushOk = sync.clientOnUvPushOk;
const clientSendsCiUnlock = sync.clientSendsCiUnlock;
const clientSendsContent = sync.clientSendsContent;
const clientSendsSendPrivate = sync.clientSendsSendPrivate;
const cloneSeqnoIsFatal = sync.cloneSeqnoIsFatal;
const cloneServerMustAdvance = sync.cloneServerMustAdvance;
const clone_version = sync.clone_version;
const commitMayProceedAfterCiLockFail = sync.commitMayProceedAfterCiLockFail;
const computeNonce = sync.computeNonce;
const computeSignature = sync.computeSignature;
const configKind = sync.configKind;
const configNameHasSlashPrefix = sync.configNameHasSlashPrefix;
const config_push_unauthorized_text = sync.config_push_unauthorized_text;
const config_scalar_marker = sync.config_scalar_marker;
const consumeConfigPayload = sync.consumeConfigPayload;
const consumeContentPayload = sync.consumeContentPayload;
const consumeUvfilePayload = sync.consumeUvfilePayload;
const continueExchange = sync.continueExchange;
const cookie_gate_min_date = sync.cookie_gate_min_date;
const cookie_gate_min_time = sync.cookie_gate_min_time;
const cookie_gate_min_version = sync.cookie_gate_min_version;
const deltaBasisSend = sync.deltaBasisSend;
const deriveSharedSecret = sync.deriveSharedSecret;
const duplicateLogin = sync.duplicateLogin;
const encodeVersion = sync.encodeVersion;
const establishedCapabilities = sync.establishedCapabilities;
const fossilQuote = sync.fossilQuote;
const fossilUnquote = sync.fossilUnquote;
const gimmeEligible = sync.gimmeEligible;
const gimmeResponse = sync.gimmeResponse;
const igotDisposition = sync.igotDisposition;
const initialState = sync.initialState;
const integritySchedule = sync.integritySchedule;
const isCommentLine = sync.isCommentLine;
const isFatal = sync.isFatal;
const isFossilWhitespace = sync.isFossilWhitespace;
const loginTransport = sync.loginTransport;
const matchesPullOnly = sync.matchesPullOnly;
const max_line_len = sync.max_line_len;
const max_redirects = sync.max_redirects;
const max_tokens = sync.max_tokens;
const media_props = sync.media_props;
const needsSshTransportHeader = sync.needsSshTransportHeader;
const nextRequestKind = sync.nextRequestKind;
const parseCfileCard = sync.parseCfileCard;
const parseCiLock = sync.parseCiLock;
const parseCiLockFail = sync.parseCiLockFail;
const parseCiUnlock = sync.parseCiUnlock;
const parseClientUrl = sync.parseClientUrl;
const parseCloneSeqno = sync.parseCloneSeqno;
const parseConfigCard = sync.parseConfigCard;
const parseCookieLogin = sync.parseCookieLogin;
const parseFileCard = sync.parseFileCard;
const parseGimmeCard = sync.parseGimmeCard;
const parseIgotCard = sync.parseIgotCard;
const parseLoginCard = sync.parseLoginCard;
const parseMediaType = sync.parseMediaType;
const parseOperator = sync.parseOperator;
const parsePragmaName = sync.parsePragmaName;
const parsePushPull = sync.parsePushPull;
const parseReqconfigName = sync.parseReqconfigName;
const parseScalarConfig = sync.parseScalarConfig;
const parseSignalText = sync.parseSignalText;
const parseTimestampComment = sync.parseTimestampComment;
const parseUvHash = sync.parseUvHash;
const parseUvfileCard = sync.parseUvfileCard;
const parseUvgimmeCard = sync.parseUvgimmeCard;
const parseUvigotCard = sync.parseUvigotCard;
const parseVersionPragma = sync.parseVersionPragma;
const peerGatedForCookie = sync.peerGatedForCookie;
const peer_version_error_text = sync.peer_version_error_text;
const pragmaDirection = sync.pragmaDirection;
const pragmaIsArgless = sync.pragmaIsArgless;
const pragmaSchedule = sync.pragmaSchedule;
const privateIgotAction = sync.privateIgotAction;
const privateReceipt = sync.privateReceipt;
const private_igot_min_peer_date = sync.private_igot_min_peer_date;
const readLine = sync.readLine;
const receivePrivateIgot = sync.receivePrivateIgot;
const recordPeerVersion = sync.recordPeerVersion;
const redirectAllowed = sync.redirectAllowed;
const redirectAllowedForTransport = sync.redirectAllowedForTransport;
const replyBodyStep = sync.replyBodyStep;
const replyCapCheck = sync.replyCapCheck;
const replyFraming = sync.replyFraming;
const reply_body_cap = sync.reply_body_cap;
const requiredCapability = sync.requiredCapability;
const send_private_unauthorized_text = sync.send_private_unauthorized_text;
const sendableAfterCloneRound0 = sync.sendableAfterCloneRound0;
const serverCapabilityDecision = sync.serverCapabilityDecision;
const serverCiLockResult = sync.serverCiLockResult;
const serverConfigPushDecision = sync.serverConfigPushDecision;
const serverEmitsServerVersion = sync.serverEmitsServerVersion;
const serverErrorReset = sync.serverErrorReset;
const serverMethodDisposition = sync.serverMethodDisposition;
const serverReqconfigDisposition = sync.serverReqconfigDisposition;
const serverSendPrivateDecision = sync.serverSendPrivateDecision;
const serverSyncProjectCodeDecision = sync.serverSyncProjectCodeDecision;
const serverUvCatalog = sync.serverUvCatalog;
const serverUvResponse = sync.serverUvResponse;
const serverVerifyLogin = sync.serverVerifyLogin;
const sha3PeerVersionGate = sync.sha3PeerVersionGate;
const sha3_min_peer_version = sync.sha3_min_peer_version;
const ssh_transport_header = sync.ssh_transport_header;
const stripRedirectXfer = sync.stripRedirectXfer;
const syncRequestTarget = sync.syncRequestTarget;
const tokenizeCard = sync.tokenizeCard;
const transition = sync.transition;
const uvCatalogHash = sync.uvCatalogHash;
const uvDecide = sync.uvDecide;
const uvGimmeReply = sync.uvGimmeReply;
const uvLandDecision = sync.uvLandDecision;
const uvLandingClearsCatalog = sync.uvLandingClearsCatalog;
const uvStatus = sync.uvStatus;
const uvfilePayloadFollows = sync.uvfilePayloadFollows;
const uvfile_flag_omitted = sync.uvfile_flag_omitted;
const verifyUvContent = sync.verifyUvContent;
const writeBadCommand = sync.writeBadCommand;
const writeCiLock = sync.writeCiLock;
const writeCiLockFail = sync.writeCiLockFail;
const writeCiUnlock = sync.writeCiUnlock;
const writeClientUrl = sync.writeClientUrl;
const writeClientVersion = sync.writeClientVersion;
const writeCloneCard = sync.writeCloneCard;
const writeConfigCard = sync.writeConfigCard;
const writeCookieCard = sync.writeCookieCard;
const writeCookieLogin = sync.writeCookieLogin;
const writeLogin = sync.writeLogin;
const writeFlagPragma = sync.writeFlagPragma;
const writeMalformedCard = sync.writeMalformedCard;
const writePushPullCard = sync.writePushPullCard;
const writeReqconfig = sync.writeReqconfig;
const writeScalarConfig = sync.writeScalarConfig;
const writeServerVersion = sync.writeServerVersion;
const writeSignalCard = sync.writeSignalCard;
const zlibContainerDecode = sync.zlibContainerDecode;
const zlibContainerEncode = sync.zlibContainerEncode;
test "client starts in init" {
const c: Client = .{};
try std.testing.expectEqual(ClientState.init, c.state);
}
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/§12.3-r2: card and comment lines bounded at 65535 bytes including LF" {
// §12.3-r2 L2755-2760: the 65535-byte line bound is a fatal-error bound —
// an over-length line (no LF within the window) is MissingTerminator, the
// resource-exhaustion guard restated here.
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 §12.3-r1: the line bound is measured through LF, excluding the following byte-counted payload" {
// §12.3 L2757-2758: the bound is measured through the terminating LF and
// excludes any following byte-counted payload. A short card line followed
// by a payload larger than the whole 65535 bound still parses — readLine
// stops at the early LF and never scans the payload.
const hash = "a" ** 40;
const card_line = "file " ++ hash ++ " 100000\n"; // 53 bytes, LF at index 52
const payload_len: usize = max_line_len + 64;
var body: [card_line.len + payload_len]u8 = undefined;
@memcpy(body[0..card_line.len], card_line);
@memset(body[card_line.len..], 'x'); // non-LF byte-counted payload
const line = try readLine(&body, 0);
try std.testing.expectEqual(@as(usize, card_line.len - 1), line.bytes.len); // 52, excludes LF
try std.testing.expectEqual(@as(usize, card_line.len), line.next); // 53, just past LF
// The trailing payload exceeds the bound, yet readLine succeeded: it found
// the LF within the window without scanning into the payload.
try std.testing.expect(body.len - line.next > max_line_len);
}
test "sync §12.3-r3: an over-length line aborts before card dispatch (replyBodyStep .truncated, never .card_ready)" {
// §12.3 L2760-2762: the receiver MUST abort before interpreting an
// over-length line as a card or consuming any subsequent payload.
// replyBodyStep runs readLine before it can return .card_ready, so an
// unterminated over-length line yields .truncated and never dispatches.
const over: usize = max_line_len + 50;
var body: [over + 1]u8 = undefined;
@memset(body[0..over], 'x'); // no LF within the window
body[over] = '\n'; // LF sits beyond the bound
try std.testing.expectEqual(ReplyBodyStep.truncated, replyBodyStep(&body, 0));
try std.testing.expect(replyBodyStep(&body, 0) != .card_ready);
try std.testing.expectError(LineError.MissingTerminator, readLine(&body, 0));
}
test "sync §12.3-r4: a declared 70000-byte count advances past the 65535 line bound" {
// §12.3 L2763-2766: the line bound does not cap the SIZE/CSIZE payloads of
// file/cfile/uvfile/config cards, which remain governed by their declared
// byte counts. 70000 is the smallest round value above the 65535 bound.
const hash = "a" ** 40;
const card_line = "file " ++ hash ++ " 70000\n"; // 52 bytes
const declared: usize = 70000;
const sentinel = "pong\n";
var body: [card_line.len + declared + sentinel.len]u8 = undefined;
@memcpy(body[0..card_line.len], card_line);
@memset(body[card_line.len..][0..declared], 'y'); // non-LF declared payload
@memcpy(body[card_line.len + declared ..], sentinel);
const first = try readLine(&body, 0);
try std.testing.expectEqualStrings("file " ++ hash ++ " 70000", first.bytes);
// Caller advances past the declared byte count, not by re-scanning for LF.
const advance = first.next + declared;
const second = try readLine(&body, advance);
try std.testing.expectEqualStrings("pong", second.bytes);
}
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);
}
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 §3.5: a pragma is an ordinary body card parsed in round order [sync-3-5-r1]" {
// L693-694 — pragmas are ordinary body cards parsed in round order
// along with all other body cards; the pragma dispatches at its
// positional offset, not pre-filtered out of band.
const body = "igot H1\npragma client-version 20000\ngimme H2\n";
const want = [_]Operator{ .igot, .pragma, .gimme };
var offset: usize = 0;
for (want) |op| {
try std.testing.expectEqual(ReplyBodyStep.card_ready, replyBodyStep(body, offset));
const line = try readLine(body, offset);
const cc = classifyCard(line.bytes);
try std.testing.expectEqual(op, cc.operator.?);
try std.testing.expectEqual(Disposition.dispatch, cc.disposition);
offset = line.next;
}
try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, offset));
}
test "sync §3.5: an unknown pragma name is not a protocol error [sync-3-5-r2]" {
// L694-696 — unknown pragma names are silently ignored and MUST NOT be
// treated as protocol errors. The pragma card still dispatches (the
// operator resolves); only the NAME is unrecognized.
const cc = classifyCard("pragma future-x arg");
try std.testing.expectEqual(Operator.pragma, cc.operator.?);
try std.testing.expectEqual(Disposition.dispatch, cc.disposition);
try std.testing.expect(!isFatal(cc.disposition, .client));
try std.testing.expect(!isFatal(cc.disposition, .server));
try std.testing.expect(classifyPragma(cc.tokens).name == null);
}
test "sync §3.5: an unknown non-pragma operator is a fatal protocol error [sync-3-5-r3]" {
// L696 — an unknown non-pragma operator remains a fatal protocol error
// (§5-r12: unknown operator is fatal on either side).
const cc = classifyCard("frobnicate x y");
try std.testing.expect(cc.operator == null);
try std.testing.expectEqual(Disposition.unknown_operator, cc.disposition);
try std.testing.expect(isFatal(cc.disposition, .client));
try std.testing.expect(isFatal(cc.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);
}
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: recorded ci-lock-fault blocks only a fork-creating commit without an override" {
// §10.4 L2548-2556: blocks iff a fault is recorded AND the commit
// would fork AND no override was requested -- exhaustive 8-row
// truth table, one row per combination.
const Case = struct { fault: bool, fork: bool, override: bool, want: bool };
const cases = [_]Case{
.{ .fault = false, .fork = false, .override = false, .want = true },
.{ .fault = false, .fork = false, .override = true, .want = true },
.{ .fault = false, .fork = true, .override = false, .want = true },
.{ .fault = false, .fork = true, .override = true, .want = true },
.{ .fault = true, .fork = false, .override = false, .want = true },
.{ .fault = true, .fork = false, .override = true, .want = true },
.{ .fault = true, .fork = true, .override = false, .want = false }, // the single blocked row
.{ .fault = true, .fork = true, .override = true, .want = true },
};
for (cases) |c| {
try std.testing.expectEqual(c.want, commitMayProceedAfterCiLockFail(c.fault, c.fork, c.override));
}
}
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));
}
}
test "sync §11.1 r1: an error card is 'error TEXT' (fatal outside clone round 0)" {
// L2604-2607. The "fatal except the first clone round" half is realized by
// r4 (terminate elsewhere) and r10 (discard in round 0) below.
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.1 r2: error TEXT is fossilize-encoded, spaces as \\s" {
// L2609-2610.
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("error a\\sb", writeSignalCard(.err, "a b", &buf));
}
test "sync §11.1 r3: a receiver defossilizes error TEXT before display/log" {
// L2611-2612.
var decode_buf: [8]u8 = undefined;
const decoded = parseSignalText(tokenizeCard("error a\\sb"), &decode_buf).?;
try std.testing.expectEqualStrings("a b", decoded);
}
test "sync §11.1 r4: an error card outside clone round 0 stops processing and terminates" {
// L2614-2616. "Stop processing the response body" is realized as
// terminating the sync loop; every non-round-0 state terminates.
const states = [_]ClientState{ .init, .exchange, .terminated, .aborted };
for (states) |s| try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(s));
}
test "sync §11.1 r5: no retry via alternative encoding or hash algorithm (rejection)" {
// L2616-2617; A.2 "No SHA1 retry" L3219. The client's only dispositions are
// discard/terminate -- no "retry" variant, so a retry is unrepresentable.
const fields = @typeInfo(ClientErrorAction).@"enum".fields;
try std.testing.expectEqual(@as(usize, 2), fields.len);
try std.testing.expectEqualStrings("discard", fields[0].name);
try std.testing.expectEqualStrings("terminate", fields[1].name);
try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
}
test "sync §11.1 r6/r8: reset sites discard prior output; append sites extend it" {
const Case = struct { site: ErrorSite, want: ErrorReset };
const cases = [_]Case{
// r6 L2619-2623 -- fatal protocol-error sites reset prior partial output.
.{ .site = .wrong_project, .want = .reset },
.{ .site = .auth_failure, .want = .reset },
.{ .site = .duplicate_login, .want = .reset },
.{ .site = .unknown_operator, .want = .reset },
// r8 L2626-2629 -- gate / send-private / private sites append to the
// already-built response instead of resetting it.
.{ .site = .peer_version_gate, .want = .append },
.{ .site = .unauthorized_send_private, .want = .append },
.{ .site = .private_card, .want = .append },
};
for (cases) |c| try std.testing.expectEqual(c.want, serverErrorReset(c.site));
}
test "sync §11.1 r7: a reset does not promise an error-only response body" {
// L2623-2626. The disposition is binary {reset,append}; there is no
// "error_only" variant, so a reset governs only prior-output discard --
// the unauthorized-clone site still emits a push card after the reset and
// before the error card.
const fields = @typeInfo(ErrorReset).@"enum".fields;
try std.testing.expectEqual(@as(usize, 2), fields.len);
try std.testing.expectEqualStrings("reset", fields[0].name);
try std.testing.expectEqualStrings("append", fields[1].name);
try std.testing.expectEqual(ErrorReset.reset, serverErrorReset(.auth_failure));
}
test "sync §11.1 r9: the error is signalled in the body, not an HTTP status" {
// L2639-2640; A.2 header "HTTP 200" L3203. classifyCard detects the error
// in the body; clientOnErrorCard takes no status input.
try std.testing.expectEqual(Operator.@"error", classifyCard("error abc").operator.?);
}
test "sync §11.1 r10: the first clone round silently discards an error card" {
// L2642-2644.
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}
test "sync §11.1 r11: the SHA3 peer-version gate text is exact" {
// L2648-2650 (decoded text); encoded form per A.2 L3206.
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.1 App. A.2: SHA3 version-gate error card, discarded by a round-0 clone" {
// First response-body line L3206; decoded text L3211. The 191-byte
// Content-Length (L3204) is already pinned by the §7.6 App. A.2 test below,
// so it is not re-asserted here.
var buf: [96]u8 = undefined;
try std.testing.expectEqualStrings(
"error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.",
writeSignalCard(.err, peer_version_error_text, &buf),
);
var decode_buf: [64]u8 = undefined;
const line = "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.";
const decoded = parseSignalText(tokenizeCard(line), &decode_buf).?;
try std.testing.expectEqualStrings("Fossil version 2.0 or later required.", decoded);
// A round-0 clone client silently discards it (L3221-3227); no SHA1 retry
// (L3219) -- the disposition enum has no retry variant.
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}
test "sync §11.2 r1: a message card is 'message TEXT', a non-fatal advisory" {
// L2654-2656.
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.2 r2: message TEXT is encoded identically to an error card" {
// L2658. Same single-text-argument encoder: the tail after the operator
// word is byte-identical between the two cards.
var mbuf: [16]u8 = undefined;
var ebuf: [16]u8 = undefined;
const msg = writeSignalCard(.message, "a b", &mbuf);
const err = writeSignalCard(.err, "a b", &ebuf);
try std.testing.expectEqualStrings("message a\\sb", msg);
try std.testing.expectEqualStrings(err["error ".len..], msg["message ".len..]);
}
test "sync §11.2 r1: a written message card tokenizes to exactly two tokens" {
// §11.2 item preamble — "exactly two whitespace-separated tokens". TEXT is
// fossilize-encoded, so any embedded SP/LF becomes an escape and never a
// second separator: the card always splits into `message` + one argument,
// never surplus, even for whitespace-laden text.
var buf: [64]u8 = undefined;
const texts = [_][]const u8{ "a b", "x\ny", "c\\d", "pull only " };
for (texts) |t| {
const card = writeSignalCard(.message, t, &buf);
const toks = tokenizeCard(card);
try std.testing.expectEqual(@as(usize, 2), toks.len);
try std.testing.expect(!toks.surplus);
try std.testing.expectEqualStrings("message", toks.items[0]);
}
}
test "sync §11.2 r2: message TEXT round-trips fossilize/defossilize" {
// §11.2 item preamble — TEXT uses the error-card encoding; a receiver
// defossilizes with the same scheme. parseSignalText(tokenize(write)) is
// the identity on the original text.
var enc: [64]u8 = undefined;
var dec: [64]u8 = undefined;
const texts = [_][]const u8{ "a b", "x\ny", "c\\d", "pull only for now" };
for (texts) |t| {
const card = writeSignalCard(.message, t, &enc);
const got = parseSignalText(tokenizeCard(card), &dec).?;
try std.testing.expectEqualStrings(t, got);
}
}
test "sync §11.2 r3: a message card never terminates the session" {
// §11.2 item 2/3 — the client's dispositions are {pull_only_silent,
// display_optional}; neither aborts or errors the exchange.
const fields = @typeInfo(MessageAction).@"enum".fields;
try std.testing.expectEqual(@as(usize, 2), fields.len);
try std.testing.expectEqualStrings("pull_only_silent", fields[0].name);
try std.testing.expectEqualStrings("display_optional", fields[1].name);
try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard(true, "hello"));
}
test "sync §11.2 r7: pull only GLOB is case-sensitive with a fixed 'pull only ' prefix" {
// §11.2 item 1 — "the comparison is case-sensitive; the literal ASCII
// sequence pull only ... separated by a single SP and followed by a
// mandatory trailing SP is the fixed prefix". Anything not carrying that
// exact 10-byte prefix at offset 0 fails.
const Case = struct { text: []const u8, want: bool };
const cases = [_]Case{
.{ .text = "pull only ", .want = true }, // exact prefix, empty suffix
.{ .text = "Pull only ", .want = false }, // case-sensitive
.{ .text = "PULL ONLY ", .want = false }, // case-sensitive
.{ .text = "pull only ", .want = false }, // double SP != single-SP prefix
.{ .text = "pull only", .want = false }, // no mandatory trailing SP
.{ .text = "xpull only ", .want = false }, // prefix not at start
};
for (cases) |c| try std.testing.expectEqual(c.want, matchesPullOnly(c.text));
}
test "sync §11.2 r8: worked-example pull only payloads match; no whitespace trimming" {
// §11.2 item 1 worked examples, verbatim: a bare "pull only " (trailing SP,
// empty suffix) matches, as do "pull only for now" and
// "pull only pushing disabled". "No trimming of surrounding whitespace is
// performed", so a leading SP defeats the fixed-prefix match.
const Case = struct { text: []const u8, want: bool };
const cases = [_]Case{
.{ .text = "pull only ", .want = true },
.{ .text = "pull only for now", .want = true },
.{ .text = "pull only pushing disabled", .want = true },
.{ .text = " pull only ", .want = false }, // leading SP, no trimming
};
for (cases) |c| try std.testing.expectEqual(c.want, matchesPullOnly(c.text));
}
test "sync §11.2 r4/r9/r14: clear only when push is live and the payload matches" {
// §11.2 item 1 clears iff push intent is live AND payload matches pull
// only * (r4); every other combination is item 2's advisory path — a
// non-matching payload (r9) or a match while push is already clear (r14).
const Case = struct { push_active: bool, text: []const u8, want: MessageAction };
const cases = [_]Case{
.{ .push_active = true, .text = "pull only ", .want = .pull_only_silent }, // r4 empty suffix
.{ .push_active = true, .text = "pull only now", .want = .pull_only_silent }, // r4 suffix
.{ .push_active = true, .text = "hello", .want = .display_optional }, // r9 non-match
.{ .push_active = true, .text = "pull only", .want = .display_optional }, // r9 bare, no trailing SP
.{ .push_active = false, .text = "pull only ", .want = .display_optional }, // r14 push already clear
.{ .push_active = false, .text = "hello", .want = .display_optional }, // r9 non-match, pull
};
for (cases) |c| try std.testing.expectEqual(c.want, clientOnMessageCard(c.push_active, c.text));
}
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 §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));
}
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));
}
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));
}
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]);
}
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.content_queued = 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.content_queued = 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));
}
test "sync §3.4: the modern-clone operation card is clone 3 SEQNO [sync-3-4-r1]" {
// L684 — "a normal sequence of sync rounds whose operation card is
// clone 3 SEQNO." SEQNO 42 is arbitrary non-negative, distinct from
// §8.1's worked `clone 3 7`.
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("clone 3 42", try writeCloneCard(42, &buf));
// clone is an ordinary dispatchable operator, not a special-cased path.
const cc = classifyCard("clone 3 42");
try std.testing.expectEqual(Operator.clone, cc.operator.?);
try std.testing.expectEqual(Disposition.dispatch, cc.disposition);
}
test "sync §3.4: clone replies ride the operation-agnostic dispatch loop [sync-3-4-r2]" {
// L688-689 — clone replies are processed by the "same card dispatch
// loop" as other rounds. Sameness is structural: the dispatch
// primitives take no Operation parameter, so no round can branch the
// loop by operation; adding such a parameter would trip this guard.
inline for (.{ classifyCard, isFatal, replyBodyStep, readLine }) |f| {
inline for (@typeInfo(@TypeOf(f)).@"fn".params) |p| {
if (p.type) |t| try std.testing.expect(t != Operation);
}
}
// Runtime drive: a mixed clone-round reply body walks that one loop.
const body = "igot H1\npragma send-catalog\ngimme H2\n";
const want = [_]Operator{ .igot, .pragma, .gimme };
var offset: usize = 0;
for (want) |op| {
try std.testing.expectEqual(ReplyBodyStep.card_ready, replyBodyStep(body, offset));
const line = try readLine(body, offset);
const cc = classifyCard(line.bytes);
try std.testing.expectEqual(op, cc.operator.?);
try std.testing.expectEqual(Disposition.dispatch, cc.disposition);
try std.testing.expect(!isFatal(cc.disposition, .client));
offset = line.next;
}
try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, offset));
}
test "sync §3.4: clone replies use the same termination rule as other rounds [sync-3-4-r3]" {
// L688-689 ("same ... termination rule") + §3.7 (a)-(f): one function
// decides continuation for all four operations. Shared condition (f)
// (L790-792, a reqconfig deferred during clone or otherwise required)
// applies uniformly; clone-specific (e) (L765-769) lives inside it.
for ([_]Operation{ .pull, .push, .sync, .clone }) |op| {
try std.testing.expect(!continueExchange(.{ .operation = op }));
try std.testing.expect(continueExchange(.{ .operation = op, .config_exchange_pending = true }));
}
try std.testing.expect(continueExchange(.{ .operation = .clone, .first_clone_round = true }));
}
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));
}
test "sync §3.9 r1: an unknown non-pragma card operator is a fatal reject" {
// §3.9 L983-985 — client-detected, so fatal in every state, round 0
// included: it never arrives as a server error card.
try std.testing.expect(causeIsFatal(.exchange, .unknown_operator));
try std.testing.expect(causeIsFatal(.clone_round0, .unknown_operator));
const c = classifyCard("frobnicate x");
try std.testing.expectEqual(Disposition.unknown_operator, c.disposition);
try std.testing.expect(isFatal(c.disposition, .client));
try std.testing.expect(isFatal(c.disposition, .server));
}
test "sync §3.9 r2: a malformed content card is a fatal reject" {
// §3.9 L983-985 "and a malformed content card (Section 7)" — a
// content-bearing operator carrying surplus tokens (§5 L234-235).
try std.testing.expect(causeIsFatal(.exchange, .malformed_content));
try std.testing.expect(causeIsFatal(.clone_round0, .malformed_content));
const c = classifyCard("file a b c d e f g");
try std.testing.expectEqual(Disposition.malformed_content, c.disposition);
try std.testing.expect(isFatal(c.disposition, .server));
}
test "sync §3.9 r3: the liberality set is exactly the four named points, strict complement fatal" {
// §3.9 L990-999 — liberal ∪ strict partitions the posture; the four
// named points (L992-998) each tolerate, every other malformed input
// aborts (L998-999). Each case asserts its backing mechanism tolerant.
// (1) unknown pragma name is ignored (§10).
const pragma_tokens = tokenizeCard("pragma no-such");
try std.testing.expect(classifyPragma(pragma_tokens).name == null);
try std.testing.expectEqual(Disposition.dispatch, classifyCard("pragma no-such").disposition);
// (2) runs of Fossil whitespace between tokens accepted (§5).
const spaced = tokenizeCard("push a\t\tb");
const canonical = tokenizeCard("push a b");
try std.testing.expectEqual(canonical.len, spaced.len);
for (0..canonical.len) |i| try std.testing.expectEqualStrings(canonical.items[i], spaced.items[i]);
// (3) a cookie card from an unexpected server is tolerated (§3.6).
const cookie = classifyCard("cookie a\\sb");
try std.testing.expectEqual(Disposition.dispatch, cookie.disposition);
try std.testing.expect(!isFatal(cookie.disposition, .server));
// (4) an error card in the first clone round is discarded (§11.1).
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
// Strict complement — unknown operator and malformed content abort on
// either side, and no fatal cause names a cookie or a pragma.
try std.testing.expect(isFatal(.unknown_operator, .server));
try std.testing.expect(isFatal(.malformed_content, .server));
inline for (@typeInfo(FatalCause).@"enum".fields) |f| {
try std.testing.expect(!std.mem.eql(u8, f.name, "cookie"));
try std.testing.expect(!std.mem.eql(u8, f.name, "pragma"));
}
}
fn scanCanonical(bytes: []const u8) !void {
try std.testing.expect(bytes.len > 0);
try std.testing.expect(!isFossilWhitespace(bytes[0]));
try std.testing.expect(!isFossilWhitespace(bytes[bytes.len - 1]));
for (bytes, 0..) |b, i| {
if (!isFossilWhitespace(b)) continue;
try std.testing.expectEqual(@as(u8, ' '), b); // separator is one space
try std.testing.expect(i + 1 < bytes.len and !isFossilWhitespace(bytes[i + 1]));
}
try std.testing.expect(!tokenizeCard(bytes).surplus);
}
test "sync §3.9 r4: senders emit the canonical single-space form" {
// §3.9 L994-995 — every card writer emits the form receivers rely on:
// single-space separators, no surrounding or doubled whitespace.
var buf: [128]u8 = undefined;
try scanCanonical(try writeCloneCard(1, &buf));
try scanCanonical(try writePushPullCard(.push, "SC", "PC", &buf));
try scanCanonical(try writePushPullCard(.pull, "SC", "PC", &buf));
try scanCanonical(writeSignalCard(.err, "abc", &buf));
try scanCanonical(writeSignalCard(.message, "hi", &buf));
try scanCanonical(writeCookieCard("tok", &buf));
try scanCanonical(try writeClientVersion(.{ .version = 20000 }, &buf));
}
test "sync §3.9 r4: a receiver does not preserve surplus trailing tokens" {
// §3.9 L995-996 — past the six-slot window the 7th+ token is dropped
// and flags surplus; surplus is non-dispatch on both branches: a
// content card is .malformed_content (§5 L234-235), a simple card is
// .bad_command (§5 L235 else).
const t = tokenizeCard("push a b c d e f g");
try std.testing.expectEqual(@as(usize, max_tokens), t.len);
try std.testing.expect(t.surplus);
try std.testing.expectEqual(Disposition.malformed_content, classifyCard("file a b c d e f g").disposition);
try std.testing.expectEqual(Disposition.bad_command, classifyCard("push a b c d e f g").disposition);
}
test "sync §3.9 r5: a receiver accepts Fossil-whitespace runs between tokens" {
// §3.9 L992-994 — the compatibility extension: multi-byte separators
// tokenize identically to the canonical single-space form.
const spaced = tokenizeCard("push a\t\tb");
const canonical = tokenizeCard("push a b");
try std.testing.expectEqual(canonical.len, spaced.len);
for (0..canonical.len) |i| try std.testing.expectEqualStrings(canonical.items[i], spaced.items[i]);
}
test "sync §3.9 r6: an unexpected-server cookie card is tolerated" {
// §3.9 L996-997 (§3.6) — a cookie card dispatches, is never fatal, and
// no fatal cause names a cookie.
const c = classifyCard("cookie a\\sb");
try std.testing.expectEqual(Disposition.dispatch, c.disposition);
try std.testing.expect(!isFatal(c.disposition, .server));
inline for (@typeInfo(FatalCause).@"enum".fields) |f| {
try std.testing.expect(!std.mem.eql(u8, f.name, "cookie"));
}
}
test "sync §3.9 r7: an error card in the first clone round is discarded" {
// §3.9 L997-998 (§11.1) — round 0 discards, every other state terminates.
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
}
test "sync §3.9 r8: outside the four liberal points a receiver applies a rule or aborts" {
// §3.9 L998-999 — an input that is none of the four liberal points and
// not a valid card aborts; there is no tolerant fall-through.
const c = classifyCard("frobnicate");
try std.testing.expectEqual(Disposition.unknown_operator, c.disposition);
try std.testing.expect(isFatal(c.disposition, .server));
}
test "sync §3.10 r1: the request-order restatement adds no constraint checkRequestOrder lacks" {
// §3.10 L1016-1018 — §3.10 restates §3.2 and defers to it: a Table-4
// clone request is well-ordered; a §3.2 violation still yields the §3.2
// outcome, so §3.10 introduces no new order rule.
const ok = [_]RequestCard{ .client_version, .content_request };
try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&ok));
const bad = [_]RequestCard{ .content_request, .client_version };
try std.testing.expectEqual(RequestOrder.version_after_content_request, checkRequestOrder(&bad));
}
test "sync §3.10 r2: the canonical Table-4 write order projects to a well-ordered request" {
// §3.10 L1020-1054 — request rows in write order: pragma client-version;
// operation (clone → content_request); login; gimme (→ content_request);
// file/cfile; igot; reqconfig/config/uvfile; cookie. The projection is
// well-ordered and client-version (index 0) precedes every
// content_request (L1026-1027).
const rows = [_]RequestCard{
.client_version, .content_request, .other, .content_request,
.other, .other, .other, .other,
};
try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&rows));
try std.testing.expectEqual(RequestCard.client_version, rows[0]);
for (rows, 0..) |card, i| {
if (card == .content_request) try std.testing.expect(i > 0);
}
}
test "sync §3.10 r3: every request body ends with a random-bytes comment line, not the timestamp subform" {
// §3.10 L1052-1053 (Table 4) + §5.3 L1418-1422 — the request's closing
// comment carries implementation random bytes; the timestamp/errors
// subform (§5.3 L1389) is the reply's. A representative nonce line is a
// comment but not a timestamp comment.
try std.testing.expect(isCommentLine("# 9c1f4a7e2b3d"));
try std.testing.expectEqual(@as(?TimestampComment, null), parseTimestampComment("# 9c1f4a7e2b3d"));
}
test "sync §3.10 r7: the login-card row defers to §6 presence and §8.3 round-0 suppression" {
// §3.10 L1032-1034 — §6 owns login presence; the "unless clone round 0"
// branch is §8.3's sendableAfterCloneRound0; the "unless cookie mode"
// branch is §3.6 (a stored cookie replaces the login card). §3.10 adds
// no new login rule.
const line = "login privuser ee71329ef84fb77b428c6f626ad0103a426e0652 c3ee0be781c9ebcfc6e24756cbb75e6319ca255b";
try std.testing.expect(parseLoginCard(tokenizeCard(line)) != null);
try std.testing.expect(!sendableAfterCloneRound0(.clone, 0));
try std.testing.expect(sendableAfterCloneRound0(.clone, 1));
try std.testing.expect(sendableAfterCloneRound0(.pull, 0));
}
test "sync §3.10 r4: the reply-card membership set resolves to known operators" {
// §3.10 L1058-1103 (Table 5) — every named reply card resolves to a
// catalog operator; the closing comment is a comment line.
const ops = [_][]const u8{
"pragma", "push", "pull", "igot", "file", "cfile",
"gimme", "uvfile", "uvigot", "clone_seqno", "message", "error",
};
for (ops) |op| try std.testing.expect(parseOperator(op) != null);
try std.testing.expect(isCommentLine("# timestamp 2026-07-02T18:31:13 errors 0"));
}
test "sync §3.10 r4: the reply body is an unordered applicable combination, not a positional sequence" {
// §3.10 L1058 "any applicable combination" — each reply card classifies
// to .dispatch independent of position; reversing the body changes no
// card's disposition. There is deliberately no checkReplyOrder analogue
// to the request write-order authority.
const reply = [_][]const u8{ "pull SC PC", "igot h 1", "clone_seqno 5", "message ok" };
for (reply) |line| try std.testing.expectEqual(Disposition.dispatch, classifyCard(line).disposition);
var i: usize = reply.len;
while (i > 0) {
i -= 1;
try std.testing.expectEqual(Disposition.dispatch, classifyCard(reply[i]).disposition);
}
}
test "sync §3.10 r5: every reply body ends with a timestamp/error-count comment line" {
// §3.10 L1101-1103 + §5.3 L1389 — the reply's closing comment is the
// `# timestamp DATETIME errors N` subform; DATETIME is the 19-byte fixed
// form YYYY-MM-DDTHH:MM:SS (§5.3 L1391).
const parsed = parseTimestampComment("# timestamp 2026-07-02T18:31:13 errors 0").?;
try std.testing.expectEqual(@as(u64, 0), parsed.errors);
try std.testing.expectEqualStrings("2026-07-02T18:31:13", parsed.datetime);
try std.testing.expectEqual(@as(usize, 19), parsed.datetime.len);
}
test "sync §3.10 r6: a reply push/pull advertisement is recorded but does not by itself select the operation" {
// §3.10 L1076-1081 — the push/pull card is a pure record of the server's
// identifiers; only a separate rule such as `message pull only *`
// (→ .pull_only_silent) changes the selected operation.
const rec = parsePushPull(tokenizeCard("pull SC PC")).?;
try std.testing.expectEqualStrings("SC", rec.servercode);
try std.testing.expectEqualStrings("PC", rec.projectcode);
try std.testing.expectEqual(MessageAction.pull_only_silent, clientOnMessageCard(true, "pull only *"));
try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard(true, "pull SC PC"));
}
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, .{});
}
// §A.1 L4108-4109 — the two hashes the server gimmes because the client holds
// them (the client announced igot for both, L4099-4100). The manifest is the
// 272-byte checkin (L4123); alpha.txt is the 26-byte "alpha content from
// client\n" (L4131-4132, L4153).
const a1_manifest_hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
const a1_send_hash = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15";
// §A.1 L4101 — the third artifact the client already holds (shared by clone),
// and L4097-4098 the two repository identifiers carried in the operation cards.
const a1_held_hash = "c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57";
const a1_servercode = "da3e69ab0655a7dd2aba2520b2d3302a97c85787";
const a1_projectcode = "aa87ea41b0bb05ba528f7a3bdba700d7e23d6318";
test "sync §3.7(b)/§3.3: a gimme for held content exposes the hash in the send queue" {
// §3.2 L761-766 — a push/sync client that holds a gimme'd hash queues it
// in the per-session outbound send buffer (§3.1 L605-608).
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const rid = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n");
try std.testing.expect(obs.content_queued);
try std.testing.expectEqual(@as(usize, 1), client.send_queue.count());
try std.testing.expect(client.send_queue.contains(rid));
}
test "sync §3.3 L855-856: a gimme for unheld content queues no send" {
// The receiver does not hold HASH → it MUST silently skip: peer-lacks is
// still recorded (L848), but the send buffer stays empty.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n");
try std.testing.expect(!obs.content_queued);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
// Negative space (§3.3 L804-813 — the phantom set is closed; only igot and
// dangling deltas may extend it): an unheld gimme resolves via lookup, not
// intern, so it mints no phantom — not in the client's set, not in obs, and
// no entry appears in the store for the gimme'd hash.
try std.testing.expect(!obs.phantom_created);
try std.testing.expect(!obs.phantom_table_nonempty);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(ms.store().lookup(a1_send_hash) == null);
}
test "sync §3.3 L853/§3.2 L763: a gimme for held content on a receive-only op is not queued" {
// pull and clone are receive-only: the receiver holds HASH but the
// operation direction does not permit sending it — no content, no queue.
const ops = [_]Operation{ .pull, .clone };
for (ops) |op| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = op, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n");
try std.testing.expect(!obs.content_queued);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
}
}
test "sync §3.3 L849-853: clientSendsContent is true only for push and sync" {
const Case = struct { op: Operation, want: bool };
const cases = [_]Case{
.{ .op = .pull, .want = false },
.{ .op = .push, .want = true },
.{ .op = .sync, .want = true },
.{ .op = .clone, .want = false },
};
for (cases) |tc| try std.testing.expectEqual(tc.want, clientSendsContent(tc.op));
}
test "sync §3.7(b) L939-945: content_queued reflects sends appended this round, not the persistent buffer" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// Round 1: gimme held content → one send appended this round.
const r1 = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n");
try std.testing.expect(r1.content_queued);
try std.testing.expectEqual(@as(usize, 1), client.send_queue.count());
// Round 2: a gimme-free reply appends nothing this round; the per-round
// flag is false, yet the per-session buffer persists (§3.1 L605-608).
const r2 = try client.consumeReply(std.testing.allocator, ms.store(), "# timestamp 2026-07-02T18:31:13 errors 0\n");
try std.testing.expect(!r2.content_queued);
try std.testing.expectEqual(@as(usize, 1), client.send_queue.count());
}
test "sync §3.7(b)/§3.1 L605-608: drainSendQueue empties the per-session buffer and returns its rids" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const rid1 = try ms.store().put(a1_send_hash, "alpha content from client\n");
const rid2 = try ms.store().put(a1_manifest_hash, "manifest payload\n");
var client = Client{ .operation = .push, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\ngimme " ++ a1_manifest_hash ++ "\n");
try std.testing.expectEqual(@as(usize, 2), client.send_queue.count());
var out: [4]repostate.Rid = undefined;
const n = client.drainSendQueue(&out);
try std.testing.expectEqual(@as(usize, 2), n);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
// Order-agnostic membership: both queued rids come back.
var seen1 = false;
var seen2 = false;
for (out[0..n]) |r| {
if (r == rid1) seen1 = true;
if (r == rid2) seen2 = true;
}
try std.testing.expect(seen1 and seen2);
}
test "sync §3.7(b)/§A.1 L4131-4132: a queued gimme uploads its artifact end-to-end" {
// §A.1 — the client holds alpha.txt (b1c1b05f…, "alpha content from
// client\n", 26 bytes, L4153); on a push the server gimmes it (L4109), the
// client queues it, then uploads it as the round-2 file card (L4131-4132).
// The upload is modelled as a store-to-store transfer of the drained rid.
const payload = "alpha content from client\n";
try std.testing.expectEqual(@as(usize, 26), payload.len);
var client_store = repostate.MemStore.init(std.testing.allocator);
defer client_store.deinit();
_ = try client_store.store().put(a1_send_hash, payload);
var server_store = repostate.MemStore.init(std.testing.allocator);
defer server_store.deinit();
var client = Client{ .operation = .push, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, client_store.store(), "gimme " ++ a1_send_hash ++ "\n");
var out: [1]repostate.Rid = undefined;
const n = client.drainSendQueue(&out);
try std.testing.expectEqual(@as(usize, 1), n);
// Emit the file card: read the drained rid's hash + content and store it
// at the server, exactly the bytes the round-2 file card carries.
const rid = out[0];
const hash = client_store.store().hashOf(rid);
const content = client_store.store().content(rid).?;
_ = try server_store.store().put(hash, content);
const server_rid = server_store.store().lookup(a1_send_hash).?;
try std.testing.expectEqualStrings(payload, server_store.store().content(server_rid).?);
}
/// §A preamble L4073-4075 — card order within a message is non-normative, so a
/// request is asserted by card-SET membership: `line` must appear as a complete
/// LF-framed line of `body`. (Sufficient for the file-card-free requests below.)
fn expectHasLine(body: []const u8, line: []const u8) !void {
var it = std.mem.splitScalar(u8, body, '\n');
while (it.next()) |l| {
if (std.mem.eql(u8, l, line)) return;
}
return error.LineNotFound;
}
test "sync §A.1 L4096-4101: round-1 buildRequest announces every holding with no gimme" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// §A.1 L4099-4101 — the three artifacts the client holds before the sync.
_ = try ms.store().put(a1_manifest_hash, "manifest");
_ = try ms.store().put(a1_send_hash, "alpha");
_ = try ms.store().put(a1_held_hash, "ancestor");
var client = Client{
.operation = .sync,
.self_servercode = a1_servercode,
.project_code = a1_projectcode,
.version = .{ .version = 22800, .datetime = .{ .date = 20260311, .time = 113146 } },
};
defer client.deinit(std.testing.allocator);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(std.testing.allocator);
const body = try client.buildRequest(std.testing.allocator, ms.store(), &out);
// §3.2 r22 — the client-version pragma is the first line (§10.1 L4096).
try std.testing.expect(std.mem.startsWith(u8, body, "pragma client-version 22800 20260311 113146\n"));
// §A.1 L4097-4098 — a bidirectional sync carries both pull and push cards.
try expectHasLine(body, "pull " ++ a1_servercode ++ " " ++ a1_projectcode);
try expectHasLine(body, "push " ++ a1_servercode ++ " " ++ a1_projectcode);
// §A.1 L4099-4101 — one igot per held artifact, and no gimme (no phantoms).
try expectHasLine(body, "igot " ++ a1_manifest_hash);
try expectHasLine(body, "igot " ++ a1_send_hash);
try expectHasLine(body, "igot " ++ a1_held_hash);
try std.testing.expect(std.mem.indexOf(u8, body, "gimme ") == null);
}
test "sync §3.3 L858-860: an interned phantom emits gimme, then igot once content arrives" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().intern(a1_send_hash); // a phantom: content absent (§3.3 r24)
var client = Client{ .operation = .sync };
defer client.deinit(std.testing.allocator);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(std.testing.allocator);
// Phantom present ⇒ the client requests it with gimme, and does not igot it.
const r1 = try client.buildRequest(std.testing.allocator, ms.store(), &out);
try expectHasLine(r1, "gimme " ++ a1_send_hash);
try std.testing.expect(std.mem.indexOf(u8, r1, "igot " ++ a1_send_hash) == null);
// §3.3 L858-860 — storing content dephantomizes: now igot, no gimme.
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
const r2 = try client.buildRequest(std.testing.allocator, ms.store(), &out);
try expectHasLine(r2, "igot " ++ a1_send_hash);
try std.testing.expect(std.mem.indexOf(u8, r2, "gimme " ++ a1_send_hash) == null);
}
test "sync §6.1 L1919-1935 / §6.2 §6.4: a named credential emits a verifiable login card, anonymous none" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha");
const secret: [40]u8 = "0123456789abcdef0123456789abcdef01234567".*;
var named = Client{
.operation = .sync,
.self_servercode = a1_servercode,
.project_code = a1_projectcode,
.version = .{ .version = 22800, .datetime = .{ .date = 20260311, .time = 113146 } },
.credential = .{ .user = "drh", .secret_hex = secret },
};
defer named.deinit(std.testing.allocator);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(std.testing.allocator);
const body = try named.buildRequest(std.testing.allocator, ms.store(), &out);
// §6.2 — NONCE = SHA1(body bytes after the login LF); §6.4 SIGNATURE =
// SHA1(NONCE || secret). Recompute over the actual tail and confirm the
// emitted card matches, pinning the derivation to the RFC primitives.
const login_start = std.mem.indexOf(u8, body, "\nlogin drh ").? + 1;
const login_end = std.mem.indexOfScalarPos(u8, body, login_start, '\n').?;
const tail = body[login_end + 1 ..];
const nonce = computeNonce(tail);
const signature = computeSignature(&nonce, &secret);
var want_buf: [128]u8 = undefined;
const want = try std.fmt.bufPrint(&want_buf, "login drh {s} {s}", .{ &nonce, &signature });
try expectHasLine(body, want);
// §6.1 L1933-1935 — an anonymous session emits no login card at all.
var anon = Client{ .operation = .sync, .self_servercode = a1_servercode, .project_code = a1_projectcode, .version = .{ .version = 22800 } };
defer anon.deinit(std.testing.allocator);
var anon_out: std.ArrayListUnmanaged(u8) = .empty;
defer anon_out.deinit(std.testing.allocator);
const anon_body = try anon.buildRequest(std.testing.allocator, ms.store(), &anon_out);
try std.testing.expect(std.mem.indexOf(u8, anon_body, "login ") == null);
}
test "sync §5.3 L1789-1793: round_comment closes the request body after the content cards" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha");
var client = Client{ .operation = .sync, .round_comment = "deadbeef" };
defer client.deinit(std.testing.allocator);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(std.testing.allocator);
const body = try client.buildRequest(std.testing.allocator, ms.store(), &out);
try std.testing.expect(std.mem.endsWith(u8, body, "\n# deadbeef\n"));
}
fn consumeGimmeQueuesSend(gpa: std.mem.Allocator) !void {
// Held-content gimme under a push queues exactly one send. The store lives
// on the base allocator so only consumeReply/deinit see the failing one
// (mirrors roundTablesNoLeak), covering the send_queue.getOrPut allocation.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .push, .state = .exchange };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), "gimme " ++ a1_send_hash ++ "\n");
try std.testing.expectEqual(@as(usize, 1), client.send_queue.count());
// §7.5 store path (private file with grant) under the same failing
// allocator: exercises the store-gate branch and phantom remove.
var private_ms = repostate.MemStore.init(std.testing.allocator);
defer private_ms.deinit();
var granted = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer granted.deinit(gpa);
_ = try granted.consumeReply(gpa, private_ms.store(), "private\nfile " ++ test_hash_a ++ " 5\nHELLO");
// §7.5 private-igot getPtr / public-igot getOrPut branches: the public igot
// allocates the phantom entry (the failing allocation), the private igot
// marks it via getPtr (allocation-free).
var mark_ms = repostate.MemStore.init(std.testing.allocator);
defer mark_ms.deinit();
var marker = Client{ .operation = .sync, .state = .exchange };
defer marker.deinit(gpa);
_ = try marker.consumeReply(gpa, mark_ms.store(), "igot " ++ test_hash_a ++ "\nigot " ++ test_hash_a ++ " 1\n");
try std.testing.expectEqual(@as(usize, 1), marker.phantoms.count());
}
test "sync §3.7(b)/§7.5: consumeReply queues a send and lands private content under allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeGimmeQueuesSend, .{});
}
// §11.2 — the encoded `pull only ` payload (SP -> \s) and its non-matching
// counterpart, as they arrive on the wire (App. B.8 message-card grammar).
const msg_pull_only = "message pull\\sonly\\s\n";
const msg_non_match = "message hello\n";
// A held hash the server can gimme back; an unheld hash an igot phantoms.
const held_gimme = "gimme " ++ a1_send_hash ++ "\n";
const unheld_igot = "igot " ++ a1_manifest_hash ++ "\n";
test "sync §11.2 r3: a message card does not abort or error the exchange" {
// §11.2 preamble — "neither peer ... aborts the exchange in response to
// this card alone". A well-formed two-token card, matching or non-matching,
// dispatches without a fatal. Round termination under §3.7 is orthogonal;
// the card never reaches `.aborted`. (Malformed arity aborts — covered by
// the arity-failure tests below.)
const replies = [_][]const u8{ msg_pull_only, msg_non_match };
for (replies) |body| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(client.state != .aborted);
}
}
test "sync §11.2: a one-token message card is rejected as an arity failure" {
// §11.2 preamble (L3543) — "The card MUST consist of exactly two whitespace-
// separated tokens". A bare `message` with no TEXT is a defined-operator
// arity failure (§5.2 L1771-1773), which the client records as a local
// warning and aborts on (§5.2 L1809-1812, §3.9 L1315) — not a silent
// continue. The "never aborts" disposition governs only a well-formed card.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "message\n"));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §11.2: a three-token message card is rejected as an arity failure" {
// §11.2 preamble (L3543) — exactly two tokens. `message pull only` exposes
// three visible tokens (operator + two args), a defined-operator arity
// failure (§5.2 L1771-1773) that aborts (§5.2 L1809-1812). Distinct from a
// §5 surplus (>6 tokens) rejection, which classifyCard fires pre-dispatch.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "message pull only\n"));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §11.2 r15: the message arm no longer returns NotImplemented" {
// §11.2 REWORK — the .message dispatch arm carries the full disposition;
// it must not fall into the out-of-scope NotImplemented fence.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), msg_non_match);
}
test "sync §11.2 r2: consumeReply defossilizes TEXT before the pull only match" {
// §11.2 preamble — decoding precedes matching. The raw token
// `pull\sonly\sfor\snow` does not start with the literal prefix; only its
// decoded form `pull only for now` matches and clears push.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expect(client.pushActive());
_ = try client.consumeReply(std.testing.allocator, ms.store(), "message pull\\sonly\\sfor\\snow\n");
try std.testing.expect(!client.pushActive());
}
test "sync §11.2 r4/r5: a matching pull only * message silently clears push intent" {
// §11.2 item 1 — push live + payload matches -> clear SYNC_PUSH, no abort.
// The sans-IO layer performs no display, so r5 suppression holds trivially.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only);
try std.testing.expect(!client.pushActive());
try std.testing.expect(client.state != .aborted);
}
test "sync §11.2 r6: cleared push intent persists across rounds" {
// §11.2 item 1 — "a later round MUST NOT reinstate push based on this card
// alone". Round 1 (igot keeps the round alive) clears push; round 2's gimme
// for held content therefore queues nothing.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), unheld_igot ++ msg_pull_only);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(!client.pushActive());
const r2 = try client.consumeReply(std.testing.allocator, ms.store(), held_gimme);
try std.testing.expect(!r2.content_queued);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
try std.testing.expect(!client.pushActive());
}
test "sync §11.2 r9: a non-matching message changes no per-session flag" {
// §11.2 item 2 — "Non-matching payloads MUST NOT change any per-session
// flag". Push stays live, so a following-round gimme still queues.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), unheld_igot ++ msg_non_match);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(client.pushActive());
const r2 = try client.consumeReply(std.testing.allocator, ms.store(), held_gimme);
try std.testing.expect(r2.content_queued);
try std.testing.expectEqual(@as(usize, 1), client.send_queue.count());
try std.testing.expect(client.pushActive());
}
test "sync §11.2 r10/r12: a gimme before the clearing message keeps its queued send" {
// §11.2 item 3 — "A gimme HASH that appears earlier ... is handled before
// the clear fires ... that append is retained". The clear rewinds nothing.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), held_gimme ++ msg_pull_only);
try std.testing.expectEqual(@as(usize, 1), client.send_queue.count());
try std.testing.expect(!client.pushActive());
}
test "sync §11.2 r11: a gimme after the clearing message is not queued" {
// §11.2 item 3a — "once SYNC_PUSH is clear it MUST NOT" append a responsive
// content card for a later gimme in the same reply.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only ++ held_gimme);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
try std.testing.expect(!client.pushActive());
}
test "sync §11.2 r13: clearing push leaves pull-side obtain intent unaffected" {
// §11.2 item 3 — "Per-session flags other than SYNC_PUSH are unaffected".
// A later igot in the same reply still creates its pull-side phantom.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only ++ unheld_igot);
try std.testing.expect(obs.phantom_created);
try std.testing.expectEqual(@as(usize, 1), client.phantoms.count());
try std.testing.expect(!client.pushActive());
}
test "sync §11.2 r14: a matching message under pure pull falls through to advisory" {
// §11.2 item 3e — SYNC_PUSH already clear (pure pull) excludes item 1; the
// match is advisory, sets no flag, and never aborts.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expect(!client.pushActive());
_ = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only);
try std.testing.expect(!client.push_cleared);
try std.testing.expect(client.state != .aborted);
}
fn consumeMessageClearsPush(gpa: std.mem.Allocator) !void {
// §11.2 r2 — the message arm's defossilize scratch is the new alloc site.
// The store lives on the base allocator so only consumeReply sees the
// failing one (mirrors consumeGimmeQueuesSend).
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), msg_pull_only);
try std.testing.expect(!client.pushActive());
}
test "sync §11.2 r2: consumeReply handles a message card under allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeMessageClearsPush, .{});
}
fn nonWhitespaceByte(rand: std.Random) u8 {
// Keep the fuzzed token to one whitespace-free field so it stays a
// two-token card (§11.2 r1): no §5 surplus, so the abort path is never the
// subject under test — only the defossilize + GLOB disposition is.
while (true) {
const b = rand.int(u8);
if (!sync.isFossilWhitespace(b) and b != '\n') return b;
}
}
test "sync §11.2 fuzz: a message card decodes+matches without ever aborting" {
// Invariant (§11.2 item 1/2): after a single two-token message card, the
// client clears push iff the DECODED payload matches `pull only ` — never
// otherwise, and never a fatal abort. Fixed seed for exact replay.
var prng = std.Random.DefaultPrng.init(0x11_2_FADE);
const rand = prng.random();
var tok: [40]u8 = undefined;
var card: [128]u8 = undefined;
var dec: [40]u8 = undefined;
var round: usize = 0;
while (round < 512) : (round += 1) {
// n >= 1: an empty token would collapse `message ` to one visible token,
// now a §11.2 arity abort — out of this invariant's two-token scope.
const n = 1 + rand.uintLessThan(usize, tok.len);
for (tok[0..n]) |*b| b.* = nonWhitespaceByte(rand);
const prefix = "message ";
@memcpy(card[0..prefix.len], prefix);
@memcpy(card[prefix.len..][0..n], tok[0..n]);
card[prefix.len + n] = '\n';
const body = card[0 .. prefix.len + n + 1];
const decoded = artifact.decodeEscapes(tok[0..n], &dec);
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| {
try std.testing.expect(e == error.OutOfMemory);
continue;
};
try std.testing.expect(client.state != .aborted);
try std.testing.expectEqual(matchesPullOnly(decoded), client.push_cleared);
}
}
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));
}
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 }));
}
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));
}
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);
}
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);
}
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 }));
}
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));
}
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));
}
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));
}
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")));
}
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));
}
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 §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 trailing /xfer segments repeatedly, origin path never consulted" {
const cases = [_]struct { location: []const u8, want: []const u8 }{
// clause 2 (repeated strip): /a/xfer/xfer -> /a/xfer -> /a, stop.
.{ .location = "/a/xfer/xfer", .want = "/a" },
// clause 1 (single MUST strip): /mirror/xfer -> /mirror, stop.
.{ .location = "https://b.example/mirror/xfer", .want = "https://b.example/mirror" },
// clause 3 (verbatim): no trailing /xfer segment.
.{ .location = "/mirror/proj", .want = "/mirror/proj" },
// clause 4 (origin path never consulted): the old suffix-match
// code stripped /proj against an origin /repo/proj; the new rule
// has no origin arg and /proj != /xfer, so it's left verbatim.
.{ .location = "https://b.example/mirror/proj", .want = "https://b.example/mirror/proj" },
// no path component present: nothing strippable.
.{ .location = "https://b.example", .want = "https://b.example" },
// clause 1 at path root: authority preserved by the path-start floor.
.{ .location = "https://b.example/xfer", .want = "https://b.example" },
// authority guard: "xfer" is the host, not a path segment.
.{ .location = "https://xfer", .want = "https://xfer" },
};
for (cases) |c| {
try std.testing.expectEqualStrings(c.want, stripRedirectXfer(c.location));
}
}
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 decode that fits within declared capacity is accepted" {
// §4.1 L1210-1211: "MUST NOT require the actual decoded length to equal
// the field value when the decoder succeeds within capacity" — declared
// 99, decoded 11 <= 99, so the shorter stream is accepted as-is.
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 (capacity), stream carries 11
const decoded = try zlibContainerDecode(std.testing.allocator, lied[0..encoded.len], reply_body_cap);
defer std.testing.allocator.free(decoded);
try std.testing.expectEqualStrings("mismatch me", decoded);
}
test "sync §4.1: output exceeding declared capacity is rejected" {
// §4.1 L1209-1210: reject when the decoder cannot fit its output within
// the declared capacity — declared 5, actual 11 > 5.
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], 5, .big); // declares capacity 5, stream carries 11
try std.testing.expectError(error.OutputTooLarge, 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));
}
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));
}
test "sync §A.5/§6.2 r5/§12.1-r2: computeNonce('example-request-body-bytes')" {
// §12.1-r2 L2709-2711 restates this: the login NONCE is the SHA-1 digest
// of the message-body bytes that follow the card — exactly this vector.
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.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/§12.1-r3: distinct body tails yield distinct nonces (replay note)" {
// §12.1-r3 L2711-2713: independently generated rounds produce distinct
// nonces. Its causal mechanism — the per-request random comment line of
// §5.3 (L2711-2712) — is an RNG/CLI-layer concern deliberately out of this
// sans-IO scope; the co-citation covers only the distinct-tail =>
// distinct-nonce consequence, not the randomness source.
const n1 = computeNonce("round one body bytes");
const n2 = computeNonce("round two body bytes");
try std.testing.expect(!std.mem.eql(u8, &n1, &n2));
}
test "sync §12.1-r1: neither password nor shared secret appears in the login body card" {
// §12.1 L2704-2707: the mechanism transmits neither the user password nor
// the shared secret; neither is carried on the wire. The §6.1 body card's
// fields are USER/NONCE/SIGNATURE, and SIGNATURE = SHA-1(NONCE || secret)
// is one-way — so neither the password nor the 40-hex secret can appear.
const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase");
try std.testing.expectEqualStrings("d7a235115bf97d126c42140196c4fc6087312353", &secret); // §A.5 vector
const nonce = computeNonce("example-request-body-bytes");
const sig = computeSignature(&nonce, &secret);
var card_buf: [128]u8 = undefined;
const card = try std.fmt.bufPrint(&card_buf, "login alice {s} {s}", .{ &nonce, &sig });
try std.testing.expect(std.mem.indexOf(u8, card, "secret-passphrase") == null);
try std.testing.expect(std.mem.indexOf(u8, card, &secret) == null);
}
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);
}
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 §12.2-r5: writeCookieLogin never places the shared secret in the x-f-l-c cookie" {
// §12.2 L2733-2734 (+ §12 preamble L2679-2681): a shared secret MUST NOT be
// stored in a cookie. The x-f-l-c fields are login+USER+NONCE+SIGNATURE —
// the 40-hex secret and the password are not among them. Distinct surface
// from §12.1-r1: cookie transport, not the body card.
const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase");
const nonce = computeNonce("example-request-body-bytes");
const sig = computeSignature(&nonce, &secret);
const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &sig };
var buf: [128]u8 = undefined;
const cookie = writeCookieLogin(card, &buf).?;
try std.testing.expect(std.mem.indexOf(u8, cookie, &secret) == null);
try std.testing.expect(std.mem.indexOf(u8, cookie, "secret-passphrase") == null);
}
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 §12.1-r4: a byte-identical login verifies .ok twice (no nonce cache, no freshness check)" {
// §12.1 L2713-2717: the server maintains no nonce cache and applies no
// timestamp freshness check, so a complete request can be replayed
// byte-for-byte and still pass. serverVerifyLogin is a pure function of its
// inputs — identical inputs return .ok both times, pinning that property.
const body_tail = "some request body tail bytes";
const nonce = computeNonce(body_tail);
const stored_secret = "arbitrary-stored-credential-bytes";
const sig = computeSignature(&nonce, stored_secret);
const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &sig };
const first = serverVerifyLogin(card, stored_secret, "unused-project-code", body_tail);
const second = serverVerifyLogin(card, stored_secret, "unused-project-code", body_tail);
try std.testing.expectEqual(LoginResult.ok, first);
try std.testing.expectEqual(LoginResult.ok, second);
}
test "sync §6.5 r1: cookie gate requires peer VERSION >= 22700 (Fossil 2.27)" {
const dt: DateTime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time };
const cases = [_]struct { version: u32, want: bool }{
.{ .version = 22699, .want = false },
.{ .version = 22700, .want = true },
.{ .version = 22701, .want = true },
};
for (cases) |c| {
const peer: Peer = .{ .version = c.version, .datetime = dt };
try std.testing.expectEqual(c.want, peerGatedForCookie(peer));
}
}
test "sync §6.5 r2: cookie gate requires timestamp on or after 2025-07-27 11:05:00" {
const cases = [_]struct { date: u32, time: u32, want: bool }{
.{ .date = 20250726, .time = 999999, .want = false },
.{ .date = 20250727, .time = 110459, .want = false },
.{ .date = 20250727, .time = 110500, .want = true },
.{ .date = 20250727, .time = 110501, .want = true },
.{ .date = 20250728, .time = 0, .want = true },
};
for (cases) |c| {
const peer: Peer = .{ .version = cookie_gate_min_version, .datetime = .{ .date = c.date, .time = c.time } };
try std.testing.expectEqual(c.want, peerGatedForCookie(peer));
}
}
test "sync §6.5 r3: both conditions met -> cookie transport" {
const named = Identity{ .named = "alice" };
const peer: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } };
try std.testing.expectEqual(LoginTransport.cookie, loginTransport(named, peer, .pull, 1));
try std.testing.expect(loginTransport(named, peer, .pull, 1).emitsCookieHeader());
}
test "sync §6.5 r4: either condition unmet -> body-embedded login fallback" {
const named = Identity{ .named = "alice" };
const cases = [_]Peer{
.{ .version = 22699, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }, // low version
.{ .version = cookie_gate_min_version, .datetime = .{ .date = 20250726, .time = 235959 } }, // early date
.{ .version = cookie_gate_min_version, .datetime = .{ .date = cookie_gate_min_date, .time = 110459 } }, // early time
};
for (cases) |peer| {
try std.testing.expectEqual(LoginTransport.body, loginTransport(named, peer, .pull, 1));
try std.testing.expect(loginTransport(named, peer, .pull, 1).emitsBodyLogin());
}
}
test "sync §6.5 r5: unobserved server-version (null timestamp) falls back to body" {
const named = Identity{ .named = "alice" };
const peer = Peer.clientInit(23000);
try std.testing.expect(peer.datetime == null);
try std.testing.expect(!peerGatedForCookie(peer));
try std.testing.expectEqual(LoginTransport.body, loginTransport(named, peer, .pull, 1));
}
test "sync §6.5 r6: ungated peer never emits the x-f-l-c cookie header" {
const named = Identity{ .named = "alice" };
const cases = [_]Peer{
.{ .version = 22699, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } },
.{ .version = cookie_gate_min_version, .datetime = .{ .date = 20250101, .time = 0 } },
Peer.clientInit(23000),
};
for (cases) |peer| {
try std.testing.expect(!loginTransport(named, peer, .pull, 1).emitsCookieHeader());
}
}
test "sync §6.5 r7: cookie login plus body login is a §11.1 duplicate_login error (rejection)" {
const cases = [_]struct { cookie: bool, body: usize, want: bool }{
.{ .cookie = true, .body = 1, .want = true }, // r7: cookie + body login together
.{ .cookie = false, .body = 2, .want = true }, // §6.1 two body logins
.{ .cookie = true, .body = 0, .want = false },
.{ .cookie = false, .body = 1, .want = false },
.{ .cookie = false, .body = 0, .want = false },
};
for (cases) |c| {
try std.testing.expectEqual(c.want, duplicateLogin(c.cookie, c.body));
}
try std.testing.expectEqual(ErrorReset.reset, serverErrorReset(.duplicate_login));
}
test "sync §6.5 r8: server accepts a body login irrespective of cookie gating" {
try std.testing.expect(!duplicateLogin(false, 1));
const body_tail = "r8 body tail bytes";
const nonce = computeNonce(body_tail);
const stored_secret = "r8-stored-credential";
const signature = computeSignature(&nonce, stored_secret);
const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &signature };
// serverVerifyLogin takes no version/gate input: acceptance of a body
// login is structurally ungated, even for a peer that would also
// qualify for the cookie transport.
try std.testing.expectEqual(LoginResult.ok, serverVerifyLogin(card, stored_secret, "project", body_tail));
}
test "sync §6.5 r9: cookie-login mode suppresses the body login card" {
const named = Identity{ .named = "alice" };
const peer: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } };
const transport = loginTransport(named, peer, .pull, 1);
try std.testing.expectEqual(LoginTransport.cookie, transport);
try std.testing.expect(!transport.emitsBodyLogin());
}
test "sync §6.2 [RFC3174] §7.3: computeNonce('abc') == a9993e364706816aba3e25717850c26c9cd0d89d (absolute anchor)" {
// §6.2 L1941-1945: NONCE = SHA1(body bytes after the login-card LF).
// computeNonce('abc') is therefore SHA1('abc'), which RFC3174 §7.3 TEST1
// pins absolutely.
const nonce = computeNonce("abc");
try std.testing.expectEqualStrings("a9993e364706816aba3e25717850c26c9cd0d89d", &nonce);
}
test "sync §6.2: the nonce excludes the login line (same tail, different login -> same nonce)" {
// §6.2 L1941-1945: only the bytes AFTER the login-card LF feed the nonce.
// Two requests that differ SOLELY in their login line but share the same
// tail must yield the same nonce; hashing the whole request (login line
// included) yields a different value, proving the login line is excluded.
const tail = "the shared body tail bytes after the login line";
const req_a = "login alice " ++ ("a" ** 40) ++ " " ++ ("b" ** 40) ++ "\n" ++ tail;
const req_b = "login bob " ++ ("c" ** 40) ++ " " ++ ("d" ** 40) ++ "\n" ++ tail;
try std.testing.expect(!std.mem.eql(u8, req_a, req_b)); // the two requests genuinely differ
const tail_a = req_a[std.mem.indexOfScalar(u8, req_a, '\n').? + 1 ..];
const tail_b = req_b[std.mem.indexOfScalar(u8, req_b, '\n').? + 1 ..];
try std.testing.expectEqualStrings(tail, tail_a);
try std.testing.expectEqualStrings(tail, tail_b);
const nonce_a = computeNonce(tail_a);
const nonce_b = computeNonce(tail_b);
try std.testing.expectEqualStrings(&nonce_a, &nonce_b); // same tail -> same nonce despite different logins
// Hashing the full request (login line + tail) instead would differ.
try std.testing.expect(!std.mem.eql(u8, &nonce_a, &computeNonce(req_a)));
try std.testing.expect(!std.mem.eql(u8, &nonce_a, &computeNonce(req_b)));
}
test "sync §6.2: nonce is sensitive to body-tail suffix perturbation (changed tail -> changed nonce)" {
// §6.2 L1941-1945: the nonce covers the tail through end of body, so any
// change to those bytes changes the nonce.
const base = computeNonce("body tail bytes");
const changed = computeNonce("body tail bytesX");
try std.testing.expect(!std.mem.eql(u8, &base, &changed));
}
test "sync §6.1: loginTransport is .none for an anonymous identity (no login card)" {
// §6.1 L1932-1934: a login card MUST NOT be emitted for anonymous.
const gated: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } };
const id = Identity.fromLoginName("anonymous");
try std.testing.expectEqual(LoginTransport.none, loginTransport(id, gated, .pull, 1));
try std.testing.expect(!loginTransport(id, gated, .pull, 1).emitsBodyLogin());
try std.testing.expect(!loginTransport(id, gated, .pull, 1).emitsCookieHeader());
}
test "sync §6.1: loginTransport is .none for a nobody identity (no login card)" {
// §6.1 L1932-1934: a login card MUST NOT be emitted for nobody.
const gated: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } };
const id = Identity.fromLoginName("nobody");
try std.testing.expectEqual(LoginTransport.none, loginTransport(id, gated, .pull, 1));
}
test "sync §8.3: loginTransport is .none for a named identity on clone round 0 (neither login form)" {
// §8.3 L1974-1975: the first round of a protocol-version-3 clone omits
// both body-login and cookie-login, even for a named, gated peer.
const gated: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } };
const named = Identity{ .named = "alice" };
try std.testing.expectEqual(LoginTransport.none, loginTransport(named, gated, .clone, 0));
// A later clone round (round 1) resumes normal transport (cookie here).
try std.testing.expectEqual(LoginTransport.cookie, loginTransport(named, gated, .clone, 1));
}
test "sync §6.1: writeLogin emits `login USER NONCE SIGNATURE` with single-SP separators over a fossilized USER" {
// §6.1 L1922-1926: the body-card form is `login USER NONCE SIGNATURE`.
// USER is emitted verbatim as the already-fossilized token, so an
// escaped whitespace (backslash-s) stays a single token.
const nonce = "653828a8fc753c1685501de457c104f0b20938c9";
const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
var buf: [128]u8 = undefined;
const plain = try writeLogin(.{ .user = "alice", .nonce = nonce, .signature = sig }, &buf);
try std.testing.expectEqualStrings("login alice " ++ "653828a8fc753c1685501de457c104f0b20938c9" ++ " " ++ "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", plain);
var buf2: [128]u8 = undefined;
const escaped = try writeLogin(.{ .user = "user\\sname", .nonce = nonce, .signature = sig }, &buf2);
try std.testing.expectEqualStrings("login user\\sname " ++ "653828a8fc753c1685501de457c104f0b20938c9" ++ " " ++ "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", escaped);
}
test "sync §B.4: cookie value — ~ passes unescaped, SP -> +, $ / : are percent-encoded" {
// §B.4 L4630-4631: pct-user unreserved = ALPHA / DIGIT / - . _ ~ ; every
// other byte percent-encoded. So `~` passes, SP -> '+', and `$ / :`
// become %24 %2F %3A. (The RFC3986 unreserved set is normative here — see
// rfc_ambiguity re: a claimed wider fossil-canonical alphabet.)
const nonce = "653828a8fc753c1685501de457c104f0b20938c9";
const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
var buf: [160]u8 = undefined;
const cookie = writeCookieLogin(.{ .user = "a~b$c/d:e f", .nonce = nonce, .signature = sig }, &buf).?;
try std.testing.expectEqualStrings("login+a~b%24c%2Fd%3Ae+f+" ++ nonce ++ "+" ++ sig, cookie);
}
test "sync §B.4: cookie value — * becomes %2A" {
const nonce = "653828a8fc753c1685501de457c104f0b20938c9";
const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
var buf: [160]u8 = undefined;
const cookie = writeCookieLogin(.{ .user = "a*b", .nonce = nonce, .signature = sig }, &buf).?;
try std.testing.expectEqualStrings("login+a%2Ab+" ++ nonce ++ "+" ++ sig, cookie);
}
test "sync §B.4: cookie value — backslash becomes %5C" {
const nonce = "653828a8fc753c1685501de457c104f0b20938c9";
const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
var buf: [160]u8 = undefined;
const cookie = writeCookieLogin(.{ .user = "a\\b", .nonce = nonce, .signature = sig }, &buf).?;
try std.testing.expectEqualStrings("login+a%5Cb+" ++ nonce ++ "+" ++ sig, cookie);
}
test "sync §B.4: cookie value — a non-ASCII byte becomes uppercase %XX" {
const nonce = "653828a8fc753c1685501de457c104f0b20938c9";
const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
var buf: [160]u8 = undefined;
// 0xC3 is the lead byte of a UTF-8 'Ã'; it must encode as uppercase %C3.
const cookie = writeCookieLogin(.{ .user = "a\xc3b", .nonce = nonce, .signature = sig }, &buf).?;
try std.testing.expectEqualStrings("login+a%C3b+" ++ nonce ++ "+" ++ sig, cookie);
}
test "sync §B.4: cookie decode accepts case-insensitive %XX but rejects %20 in a separator slot" {
// §B.4 L4629-4632: url-login-value = "login+" pct-user "+" nonce "+"
// signature, with pct-encoded = "%" HEXDIG-CI HEXDIG-CI. The field
// separator is the literal "+" only; the sole leniency the grammar
// licenses is HEXDIG-CI (a pct-encoded byte spelled in either hex case).
// A lowercase escape therefore decodes exactly as its uppercase-hex
// canonical form. Using %20 where a "+" separator belongs is NOT a
// grammar separator and MUST be rejected.
const nonce = "653828a8fc753c1685501de457c104f0b20938c9";
const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2";
// '*' is not unreserved (§B.4 L4631), so writeCookieLogin percent-encodes
// it with uppercase hex: %2A.
var canon_buf: [160]u8 = undefined;
const canonical = writeCookieLogin(.{ .user = "a*b", .nonce = nonce, .signature = sig }, &canon_buf).?;
try std.testing.expectEqualStrings("login+a%2Ab+" ++ nonce ++ "+" ++ sig, canonical);
var a: [160]u8 = undefined;
const from_canon = parseCookieLogin(canonical, &a).?;
// The same value with the escape spelled in lowercase hex (%2a) decodes
// to the identical card -- HEXDIG-CI is case-insensitive.
const lower = "login+a%2ab+" ++ nonce ++ "+" ++ sig;
var b: [160]u8 = undefined;
const from_lower = parseCookieLogin(lower, &b).?;
try std.testing.expectEqualStrings("a*b", from_lower.user);
try std.testing.expectEqualStrings(from_canon.user, from_lower.user);
try std.testing.expectEqualStrings(from_canon.nonce, from_lower.nonce);
try std.testing.expectEqualStrings(from_canon.signature, from_lower.signature);
// %20 in the separator positions is not the grammar's "+" separator; the
// flat form-urldecode leniency that accepted it was not RFC-derived.
const sep_as_pct20 = "login%20a%2ab%20" ++ nonce ++ "%20" ++ sig;
var c: [160]u8 = undefined;
try std.testing.expect(parseCookieLogin(sep_as_pct20, &c) == null);
}
test "sync §6.1/§6.4 fuzz: deriveSharedSecret/computeNonce/computeSignature never crash and return 40 lowercase-hex bytes" {
// Seeded PRNG over bounded, precondition-respecting inputs: the three §6
// digest funnels always produce exactly 40 lowercase-hex bytes and never
// trip a bound. Reproduces exactly from the fixed seed.
var prng = std.Random.DefaultPrng.init(0x5116_C0DE);
const rand = prng.random();
const hex_lower = "0123456789abcdef";
var round: usize = 0;
while (round < 500) : (round += 1) {
// login: 1..16 bytes of lowercase letters (non-empty, no whitespace).
var login_buf: [16]u8 = undefined;
const login_len = 1 + rand.uintLessThan(usize, login_buf.len);
for (login_buf[0..login_len]) |*c| c.* = 'a' + rand.uintLessThan(u8, 26);
const login = login_buf[0..login_len];
var pc_buf: [40]u8 = undefined;
for (&pc_buf) |*c| c.* = rand.int(u8);
var pw_buf: [64]u8 = undefined;
const pw_len = rand.uintLessThan(usize, pw_buf.len + 1);
for (pw_buf[0..pw_len]) |*c| c.* = rand.int(u8);
const secret = deriveSharedSecret(&pc_buf, login, pw_buf[0..pw_len]);
var tail_buf: [256]u8 = undefined;
const tail_len = rand.uintLessThan(usize, tail_buf.len + 1);
for (tail_buf[0..tail_len]) |*c| c.* = rand.int(u8);
const nonce = computeNonce(tail_buf[0..tail_len]);
// nonce_hex is itself 40 lowercase hex; secret is a 40-hex string.
const sig = computeSignature(&nonce, &secret);
for ([_][]const u8{ &secret, &nonce, &sig }) |digest| {
try std.testing.expectEqual(@as(usize, 40), digest.len);
for (digest) |c| try std.testing.expect(std.mem.indexOfScalar(u8, hex_lower, c) != null);
}
}
}
test "sync §6.6 r1: the server assigns capability letters o/i/g/x; other bytes carry none" {
try std.testing.expectEqual(Capability.read, capabilityFromLetter('o').?);
try std.testing.expectEqual(Capability.write, capabilityFromLetter('i').?);
try std.testing.expectEqual(Capability.clone, capabilityFromLetter('g').?);
try std.testing.expectEqual(Capability.private, capabilityFromLetter('x').?);
for ("aszA\x00") |letter| try std.testing.expectEqual(@as(?Capability, null), capabilityFromLetter(letter));
const full = capabilitySetFromLetters("oigx");
try std.testing.expect(full.contains(.read) and full.contains(.write) and full.contains(.clone) and full.contains(.private));
try std.testing.expectEqual(@as(usize, 0), capabilitySetFromLetters("").count());
// unknown byte 'Q' is skipped, not phantom-inserted; 'o' is still held.
try std.testing.expect(capabilitySetFromLetters("oQ").contains(.read));
try std.testing.expectEqual(@as(usize, 1), capabilitySetFromLetters("oQ").count());
}
test "sync §6.6 r2: Read (o) permits pull" {
try std.testing.expectEqual(Capability.read, capabilityFromLetter('o').?);
try std.testing.expectEqual(Capability.read, requiredCapability(.pull).?);
try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(capabilitySetFromLetters("o"), .pull));
}
test "sync §6.6 r3: Write (i) permits push" {
try std.testing.expectEqual(Capability.write, capabilityFromLetter('i').?);
try std.testing.expectEqual(Capability.write, requiredCapability(.push).?);
try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(capabilitySetFromLetters("i"), .push));
}
test "sync §6.6 r4: Clone (g) permits clone" {
try std.testing.expectEqual(Capability.clone, capabilityFromLetter('g').?);
try std.testing.expectEqual(Capability.clone, requiredCapability(.clone).?);
try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(capabilitySetFromLetters("g"), .clone));
}
test "sync §6.6 r5: Private (x) permits private-content transfer" {
try std.testing.expectEqual(Capability.private, capabilityFromLetter('x').?);
const caps = capabilitySetFromLetters("x");
try std.testing.expect(caps.contains(.private));
// realized by the existing §7.5/§10.5 gates, not re-keyed here.
try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(caps.contains(.private)));
try std.testing.expectEqual(PrivateReceipt.store, privateReceipt(caps.contains(.private)));
}
test "sync §6.6 r6: administrator capabilities permit configuration (no letter)" {
try std.testing.expectEqual(@as(?Capability, null), capabilityFromLetter('a'));
try std.testing.expectEqual(@as(?Capability, null), capabilityFromLetter('s'));
// admin's configuration/user-management gate is §10.3's decider.
try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true));
try std.testing.expectEqual(ConfigPushDecision.reject, serverConfigPushDecision(false));
}
test "sync §6.6 r7: the server checks the capability appropriate to each processed card" {
try std.testing.expectEqual(Capability.read, requiredCapability(.pull).?);
try std.testing.expectEqual(Capability.write, requiredCapability(.push).?);
try std.testing.expectEqual(Capability.clone, requiredCapability(.clone).?);
try std.testing.expectEqual(@as(?Capability, null), requiredCapability(.sync));
const full = capabilitySetFromLetters("oig");
const ops = [_]Operation{ .pull, .push, .clone };
for (ops) |op| try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(full, op));
}
test "sync §6.6 r8: an unauthorized pull (no o) is rejected with an error card" {
try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(capabilitySetFromLetters("ig"), .pull));
try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(CapabilitySet.empty, .pull));
}
test "sync §6.6 r8: an unauthorized push (no i) is rejected with an error card" {
try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(capabilitySetFromLetters("og"), .push));
}
test "sync §6.6 r8: an unauthorized clone (no g) is rejected with an error card" {
try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(capabilitySetFromLetters("oi"), .clone));
}
test "sync §6.4: a login grant is additive — union never clears a baseline bit" {
// §6.4 L2021-2024: a successful login card can only add capability bits
// on top of the baseline, never remove any; the two contributors combine
// by union. Rows are (baseline_letters, grant_letters, expected_letters).
const Row = struct { baseline: []const u8, grant: []const u8, expected: []const u8 };
const rows = [_]Row{
.{ .baseline = "o", .grant = "i", .expected = "oi" }, // disjoint grant adds without clearing
.{ .baseline = "oi", .grant = "o", .expected = "oi" }, // grant subset of baseline -> unchanged
.{ .baseline = "og", .grant = "", .expected = "og" }, // empty grant leaves baseline exactly
.{ .baseline = "", .grant = "x", .expected = "x" }, // empty baseline -> grant only
.{ .baseline = "oigx", .grant = "oigx", .expected = "oigx" }, // idempotent full set
};
for (rows) |row| {
const base = capabilitySetFromLetters(row.baseline);
const grant = capabilitySetFromLetters(row.grant);
const got = establishedCapabilities(base, grant);
const expected = capabilitySetFromLetters(row.expected);
// (a) additive: every baseline bit survives the union (grant never clears one).
var it = base.iterator();
while (it.next()) |cap| try std.testing.expect(got.contains(cap));
// (b) grant applied: the grant is a subset of the established set.
try std.testing.expect(got.supersetOf(grant));
// (c) exactness: the established set equals the RFC-transcribed expectation.
try std.testing.expect(got.eql(expected));
try std.testing.expectEqual(expected.count(), got.count());
}
}
test "sync §6.6: capabilitySetFromLetters on garbage never crashes and stays bounded" {
// §6.6 L2155-2159: only the four assigned letters (o/i/g/x) name a
// capability; arbitrary wire bytes (NUL and >0x7f legal, all []const u8)
// must be skipped, so the derived set is always bounded by {o,i,g,x}.
var prng = std.Random.DefaultPrng.init(0x6666_c0de);
const rand = prng.random();
const full = capabilitySetFromLetters("oigx");
var buf: [64]u8 = undefined;
for (0..4096) |_| {
const len = rand.uintAtMost(usize, 64); // per-iteration length in 0..=64
rand.bytes(buf[0..len]);
const set = capabilitySetFromLetters(buf[0..len]);
try std.testing.expect(set.count() <= 4); // bounded by the four known caps
try std.testing.expect(full.supersetOf(set)); // only known caps ever appear
}
}
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.?);
}
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.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.?);
}
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, .{});
}
test "sync §3.3 r27: gimme parses HASH; wrong arity or hash length is malformed" {
try std.testing.expectEqualStrings(test_hash_a, (try parseGimmeCard(tokenizeCard("gimme " ++ test_hash_a))).hash);
const sha3 = "c" ** 64;
try std.testing.expectEqualStrings(sha3, (try parseGimmeCard(tokenizeCard("gimme " ++ sha3))).hash);
try std.testing.expectError(CardParseError.Malformed, parseGimmeCard(tokenizeCard("gimme")));
try std.testing.expectError(CardParseError.Malformed, parseGimmeCard(tokenizeCard("gimme deadbeef00")));
try std.testing.expectError(CardParseError.Malformed, parseGimmeCard(tokenizeCard("gimme " ++ test_hash_a ++ " x")));
}
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 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 §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));
}
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 L1758-1759: deletion is decided by HASH == \"-\", not the 0x0001 bit" {
const cases = [_]struct { line: []const u8, want: bool }{
// HASH="-", 0x0001 CLEAR: still a deletion -- HASH decides, not the bit.
.{ .line = "uvfile a 100 - 0 0", .want = true },
// HASH="-", 0x0001 set (the sender-side metadata marker).
.{ .line = "uvfile a 100 - 0 1", .want = true },
// real HASH, 0x0001 set: NOT a deletion -- the bit alone never decides.
.{ .line = "uvfile a 100 " ++ test_hash_a ++ " 0 1", .want = false },
// ordinary content, no flags.
.{ .line = "uvfile a 100 " ++ test_hash_a ++ " 42 0", .want = false },
};
for (cases) |c| {
const card = try parseUvfileCard(tokenizeCard(c.line));
try std.testing.expectEqual(c.want, card.isDeletion());
}
}
test "sync §7.3 L1748: FLAGS bit 0x0004 marks omitted content" {
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: uvfile payload-presence keys on the 0x0001/0x0004 FLAGS bits, not the HASH" {
// A real-HASH card carrying the 0x0001 bit is non-compliant, but the
// receiver frames the payload from the FLAGS bits: payload follows iff
// SIZE>0 and (FLAGS & 0x0005)==0 (the deletion and omitted-content bits).
// 0x0001 is set here, so NO payload follows and the bytes after the line
// are the next card — matching the reference receiver. Delete-vs-update is
// a separate decision that still keys on HASH (isDeletion stays false).
const card = try parseUvfileCard(tokenizeCard("uvfile f 100 " ++ test_hash_a ++ " 42 1"));
try std.testing.expect(!card.isDeletion());
try std.testing.expect(!uvfilePayloadFollows(card));
const body = "uvfile f 100 " ++ test_hash_a ++ " 42 1\n" ++ "next card";
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: 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: uvfile ignores visible tokens after its six parser slots" {
// §5.2 L1324 / §7.3-r2 — `uvfile` consumes the fixed six-slot parser
// window. Visible tokens beyond FLAGS do not make a valid prefix malformed.
const card = try parseUvfileCard(tokenizeCard("uvfile n 1 - 0 0 ignored after flags"));
try std.testing.expectEqualStrings("n", card.name);
try std.testing.expectEqual(@as(i64, 1), card.mtime);
try std.testing.expectEqualStrings("-", card.hash);
try std.testing.expectEqual(@as(u32, 0), card.size);
try std.testing.expectEqual(@as(u32, 0), card.flags);
}
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/§7.5 receive: uvigot and uvgimme preserve a real private marker for later content" {
const gpa = std.testing.allocator;
// A real private card, not seeded internal state, must remain pending
// across a valid but unsupported uvigot and bind the subsequent file.
{
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.NotImplemented,
client.consumeReply(gpa, ms.store(), "private\nuvigot a\\sb 1 - 0\n"),
);
try std.testing.expect(client.private_pending);
try std.testing.expectEqual(ClientState.exchange, client.state);
const obs = try client.consumeReply(gpa, ms.store(), "file " ++ test_hash_a ++ " 5\nHELLO");
try std.testing.expect(obs.private_content_discarded);
try std.testing.expect(!obs.private_content_delivered);
try std.testing.expect(ms.store().lookup(test_hash_a) == null);
try std.testing.expect(!client.private_pending);
}
// The same marker survives uvgimme and binds a later cfile. The invalid
// container proves the private discard path is observable before decoding.
{
const garbage = "not a fossil zlib container at all";
const cfile = try buildCfileBody(gpa, test_hash_a, 99, garbage.len, garbage);
defer gpa.free(cfile);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.NotImplemented,
client.consumeReply(gpa, ms.store(), "private\nuvgimme a\\sb\n"),
);
try std.testing.expect(client.private_pending);
try std.testing.expectEqual(ClientState.exchange, client.state);
const obs = try client.consumeReply(gpa, ms.store(), cfile);
try std.testing.expect(obs.private_content_discarded);
try std.testing.expect(!obs.private_content_delivered);
try std.testing.expect(ms.store().lookup(test_hash_a) == null);
try std.testing.expect(!client.private_pending);
}
}
test "sync §7.3 receive: uvigot deletion with nonzero SIZE aborts before unsupported boundary" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.FatalProtocolError,
client.consumeReply(gpa, ms.store(), "uvigot f 1 - 1\n"),
);
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §7.3 receive: private followed by uvfile reaches the unsupported boundary and leaves the marker pending" {
const gpa = std.testing.allocator;
const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.NotImplemented,
client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n"),
);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(client.private_pending);
try std.testing.expect(ms.store().lookup(empty_hash) == null);
}
test "sync §7.3 receive: non-ABNF numeric spellings abort before unsupported boundary" {
const gpa = std.testing.allocator;
const cases = [_][]const u8{
"uvfile f +1 - 0 0\n", // MTIME
"uvfile f 1 - -0 0\n", // SIZE
"uvfile f 1 - 0 +0\n", // FLAGS
"uvfile f 1 - 0 1_0\n", // FLAGS
"uvigot f +1 - 0\n", // MTIME
"uvigot f 1 - -0\n", // SIZE
};
for (cases) |body| {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
}
test "sync §7.3 receive: oversized FLAGS preserve omitted-content framing" {
const gpa = std.testing.allocator;
const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
// 2^128 + 4 is an arbitrary-length decimal whose defined low bits are
// 0x0004. Despite the nonzero SIZE, omitted content leaves no payload to
// frame, so a valid uvfile reaches the deliberate unsupported boundary.
{
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.NotImplemented,
client.consumeReply(gpa, ms.store(), "uvfile f 1 " ++ empty_hash ++ " 1 340282366920938463463374607431768211460\n"),
);
try std.testing.expectEqual(ClientState.exchange, client.state);
}
// 2^128 has no defined low bit. With the same nonzero SIZE and no
// payload, framing must reject it as truncated before the unsupported
// boundary; accepting an oversized token alone is insufficient.
{
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.FatalProtocolError,
client.consumeReply(gpa, ms.store(), "uvfile f 1 " ++ empty_hash ++ " 1 340282366920938463463374607431768211456\n"),
);
try std.testing.expectEqual(ClientState.aborted, client.state);
}
}
test "sync §7.3 receive: uvfile frames content and accepts omitted reserved and extra fields" {
const gpa = std.testing.allocator;
const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
const abc_hash = "a9993e364706816aba3e25717850c26c9cd0d89d";
const cases = [_][]const u8{
"uvfile f 1 " ++ abc_hash ++ " 3 0\nabc",
"uvfile f 1 - 0 0\n",
"uvfile f 1 " ++ abc_hash ++ " 3 4\n",
"uvfile f 1 " ++ abc_hash ++ " 3 8\nabc",
"uvfile f 1 " ++ empty_hash ++ " 0 0 ignored after flags\n",
};
for (cases) |body| {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
try std.testing.expect(ms.store().lookup(abc_hash) == null);
}
}
test "sync §7.3 receive: malformed UV cards abort before the unsupported boundary" {
const gpa = std.testing.allocator;
const abc_hash = "a9993e364706816aba3e25717850c26c9cd0d89d";
const cases = [_][]const u8{
"uvfile f nope " ++ abc_hash ++ " 0 0\n",
"uvfile f 1 not-a-hash 0 0\n",
"uvigot f 1 not-a-hash 0\n",
"uvfile f 1 - 3 4\n",
"uvfile f 1 " ++ abc_hash ++ " 4 0\nabc",
"uvfile f 1 " ++ abc_hash ++ " 3 0\nxyz",
"uvfile f 1 " ++ abc_hash ++ " 4294967296 0\n",
"uvigot f 1 - 0 extra\n",
"uvgimme f extra\n",
};
for (cases) |body| {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
}
test "sync §7.3/§4.3 receive: a non-canonically fossilized NAME is not rejected" {
// §7.3 L2253 — NAME is "encoded using the fossilize character-escaping
// scheme"; the section enumerates its receiver rejections (negative
// FLAGS, HASH - with SIZE>0, missing write permission) and licenses NONE
// for a non-canonical NAME encoding. §4.3 L657-663 makes the defossilize
// decoder error-free on an unrecognized escape (\q -> q, no error), and
// §5.1 L1723-1724 requires decode-before-use, not a canonical-form check.
// `bad\q` is therefore a valid NAME and must reach the unsupported
// boundary, not abort.
const gpa = std.testing.allocator;
const cases = [_][]const u8{
"uvgimme bad\\q\n",
"uvfile bad\\q 1 - 0 0\n",
"uvigot bad\\q 1 - 0\n",
};
for (cases) |body| {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.exchange, client.state);
}
}
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: 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: 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, .{});
}
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));
}
test "sync §9.2.1 r1: sync defines no branch/tag/leaf/closed/trunk wire operator" {
// L2285-2287: "The sync protocol MUST NOT treat an igot, gimme, file, or
// cfile card as an independent branch/tag operation, and it does not
// define separate wire cards for those views."
const view_tokens = [_][]const u8{ "branch", "tag", "leaf", "closed", "trunk" };
for (view_tokens) |token| {
try std.testing.expectEqual(@as(?Operator, null), parseOperator(token));
}
const carrier_tokens = [_][]const u8{ "igot", "gimme", "file", "cfile" };
const carrier_ops = [_]Operator{ .igot, .gimme, .file, .cfile };
for (carrier_tokens, carrier_ops) |token, want| {
try std.testing.expectEqual(@as(?Operator, want), parseOperator(token));
}
}
test "sync §9.2.1 r2: a view answer requires the carrying artifact to be crosslinked (stored)" {
// L2307-2309: "may rely on ... answers for the synchronized scope only
// after the artifacts that carry the relevant structural cards have
// been stored."
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
try std.testing.expectError(error.NotFound, rs.currentTrunk("trunk"));
try std.testing.expectError(error.NotFound, rs.resolveName("trunk"));
const rid = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
try std.testing.expectEqual(rid, try rs.currentTrunk("trunk"));
}
test "sync §9.2.1 r3: a delta base retained as a pending phantom does not error the view" {
// L2310-2311: "required delta bases have been resolved or retained as
// pending phantoms."
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
const store = ms.store();
// test_hash_a (the primary parent / delta base) is never crosslinked or
// stored — it stays a pending phantom.
const child = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 5, .parents = &.{test_hash_a}, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
const parent_rid = try store.intern(test_hash_a);
try std.testing.expectEqual(@as(?[]const u8, null), store.content(parent_rid));
try std.testing.expectEqual(child, try rs.currentTrunk("trunk"));
}
test "sync §9.2.1 r4: storing artifact bytes without crosslink processing yields no view" {
// L2311-2312: "and repository crosslink/tag-state processing has run
// for the stored artifacts." Bytes stored (r2 satisfied) but crosslink
// never called: still no view — necessity witness distinct from r2.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
const store = ms.store();
_ = try store.put(test_hash_a, "manifest bytes, never crosslinked");
try std.testing.expectError(error.NotFound, rs.resolveName("trunk"));
try std.testing.expectError(error.NotFound, rs.currentTrunk("trunk"));
const rid = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
try std.testing.expectEqual(rid, try rs.currentTrunk("trunk"));
}
test "sync §9.2.1 r5: reliable view holds only when stored, base-settled, and crosslink all hold" {
// L2307-2312, the full conjunction: the single case where all three
// preconditions hold is the meaningful, positive-space view.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
const child = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 5, .parents = &.{test_hash_a}, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
try std.testing.expect(rs.isLeaf(child));
try std.testing.expectEqual(child, try rs.currentTrunk("trunk"));
try std.testing.expectEqual(try rs.resolveName("trunk"), try rs.currentTrunk("trunk"));
}
test "sync §9.2.1 r6: an announced-only hash proves no final leaf/closed/trunk view (rejection)" {
// L2312-2314: "Before that point, the wire stream only proves that the
// peer announced or sent artifact hashes; it does not prove a final
// open-leaf, leaf, closed-marker, or current-trunk view."
const test_hash_c = "c" ** 40;
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
const store = ms.store();
const rid = try store.intern(test_hash_c);
try std.testing.expect(!rs.isLeaf(rid));
try std.testing.expect(!rs.isOpenLeaf(rid));
try std.testing.expectError(error.NotFound, rs.resolveName("trunk"));
}
test "sync §9.2.1 r7: each peer derives current-trunk from its own main-branch setting" {
// L2300-2303: "the protocol transports only the artifact inputs; each
// peer derives current-trunk locally, using its own main-branch
// setting."
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
_ = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
_ = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 2, .tags = &.{
.{ .type = .propagate, .name = "sym-release", .target = null, .value = "release" },
} });
try std.testing.expectEqual(try rs.resolveName("trunk"), try rs.currentTrunk("trunk"));
try std.testing.expectEqual(try rs.resolveName("release"), try rs.currentTrunk("release"));
}
test "sync §9.2.1 r8: two main-branch settings resolve different current-trunks (worked example)" {
// L2303-2305: "Current-trunk is therefore not a pure wire-synchronized
// value: two synchronized peers can resolve different current-trunks
// when their main-branch settings differ."
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
_ = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
_ = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 2, .tags = &.{
.{ .type = .propagate, .name = "sym-release", .target = null, .value = "release" },
} });
try std.testing.expect(try rs.currentTrunk("trunk") != try rs.currentTrunk("release"));
}
test "sync §9.2.1 r9: an incomplete-closure view is not a sync error and does not force a round (rejection)" {
// L2316-2322: "Later receipt of missing artifacts can change ...
// current-trunk answers ... This is not a separate sync error; it
// follows from the normal phantom termination rule in Section 3.7."
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var rs = repostate.RepoState.init(std.testing.allocator, ms.store());
defer rs.deinit();
// test_hash_a (the primary parent) stays a pending phantom, exactly as
// in r3 — the view is computed over the closure available so far.
_ = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 5, .parents = &.{test_hash_a}, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
_ = try rs.currentTrunk("trunk"); // resolves without a sync-layer error
const obs = RoundObservation{ .operation = .pull, .phantom_table_nonempty = true };
try std.testing.expect(!continueExchange(obs));
}
const elig_clear = Eligibility{ .peer_lacks = false, .shunned = false, .private = false, .private_sync_enabled = false };
test "sync §9.3: igot for an unheld obtainable artifact creates a phantom" {
// §3.3 item 1, L621-625 — igot creates a phantom iff the receiver is
// unheld and in an obtaining operation state; else it is ignored.
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 = .clone, .already_holds = false, .want = .create_phantom },
.{ .side = .server, .op = .push, .already_holds = false, .want = .create_phantom },
.{ .side = .client, .op = .pull, .already_holds = true, .want = .ignore }, // held
.{ .side = .client, .op = .push, .already_holds = false, .want = .ignore }, // not obtaining
.{ .side = .server, .op = .pull, .already_holds = false, .want = .ignore }, // not obtaining
};
for (cases) |c| try std.testing.expectEqual(c.want, igotDisposition(c.side, c.op, c.already_holds));
}
test "sync §9.3: a file/cfile card with an unheld DELTASRC creates a phantom for the source" {
// §3.3 item 2, L626-628 — a file or cfile whose DELTASRC is not held
// creates a phantom for that source; interning it leaves content absent.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const store = ms.store();
const fc = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10"));
try std.testing.expectEqualStrings(test_hash_b, fc.deltasrc.?);
const frid = try store.intern(fc.deltasrc.?);
try std.testing.expectEqual(@as(?[]const u8, null), store.content(frid));
const cc = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " 100 40"));
try std.testing.expectEqualStrings(test_hash_b, cc.deltasrc.?);
const crid = try store.intern(cc.deltasrc.?);
try std.testing.expectEqual(@as(?[]const u8, null), store.content(crid));
}
test "sync §9.3: an igot-created phantom is gimme-eligible under default filters" {
// §3.3 L641-643 — "the receiver MUST make that phantom eligible for
// gimme emission"; with no filter set it is eligible.
try std.testing.expectEqual(IgotDisposition.create_phantom, igotDisposition(.client, .pull, false));
try std.testing.expect(gimmeEligible(elig_clear));
}
test "sync §9.3: a phantom is deleted when its content is stored" {
// §3.3 L664-666 — the normal-path atomic upgrade: storing content
// removes the phantom (content(rid) transitions null -> present).
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, "stored content");
try std.testing.expect(store.content(rid) != null);
}
test "sync §9.3: dangling-delta HASH is content-received and not re-requested while DELTASRC is pending" {
// §3.3 L664-680 — the dangling-delta path: "the delta payload for HASH
// is stored as received, the phantom record for HASH is deleted, and a
// phantom record is created for DELTASRC ... The content-received flag is
// set in both paths." OPTION B derives content-received at the sync layer
// (hasTarget), so HASH drops out of gimme while its DELTASRC stays a
// genuine phantom and is re-requested.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const store = ms.store();
const src_bytes = "the DELTASRC artifact's eventual stored content";
const hash_bytes = "the dangling target, reconstructed from the basis, a bit longer";
const delta_bytes = try delta.create(std.testing.allocator, src_bytes, hash_bytes);
defer std.testing.allocator.free(delta_bytes);
// HASH and SRC both arrive as phantoms; the SRC-basis delta is retained.
_ = try store.intern(test_hash_a); // HASH phantom, deleted below on receipt
_ = try store.intern(test_hash_b); // DELTASRC phantom (§3.3 item 2)
var queue = PendingDeltaQueue{};
try queue.retain(test_hash_a, test_hash_b, delta_bytes);
// Pre-basis: HASH is content-received (a retained delta targets it), SRC
// is a genuine phantom (no retained payload).
try std.testing.expect(queue.hasTarget(test_hash_a));
try std.testing.expect(!queue.hasTarget(test_hash_b));
// The composed gimme gate `!content_received and gimmeEligible`: HASH is
// excluded, exactly SRC is re-requested.
try std.testing.expect(!(!queue.hasTarget(test_hash_a) and gimmeEligible(elig_clear)));
try std.testing.expect(!queue.hasTarget(test_hash_b) and gimmeEligible(elig_clear));
// The basis arrives: the delta reconstructs, the entry drains.
_ = try store.put(test_hash_b, src_bytes);
try queue.completeFor(std.testing.allocator, store, test_hash_b);
// Post-basis: the queue entry is gone AND HASH now holds reconstructed
// content, so both halves of the disjunction still exclude it from gimme.
try std.testing.expect(!queue.hasTarget(test_hash_a));
const hash_rid = try store.intern(test_hash_a);
try std.testing.expect(store.content(hash_rid) != null);
try std.testing.expectEqualStrings(hash_bytes, store.content(hash_rid).?);
try std.testing.expect(!(store.content(hash_rid) == null and !queue.hasTarget(test_hash_a) and gimmeEligible(elig_clear)));
}
test "sync §9.3: a strategy requests missing content only via igot+gimme (single rule surface)" {
// §9.3 L2328-2331 — the only path to request missing content: igot
// creates the phantom, gimme requests it under the §3.3 filters. No
// other operator originates a content request.
try std.testing.expectEqual(IgotDisposition.create_phantom, igotDisposition(.client, .pull, false));
try std.testing.expect(gimmeEligible(elig_clear)); // would emit gimme HASH
// The inbound mirror governs the reply; held+sendable+disclosable sends.
try std.testing.expectEqual(GimmeResponse.send, gimmeResponse(true, true, true));
}
test "sync §9.4: the per-round peer-lacks, shun, and private filters are applied" {
// §9.4 L2334-2337 + §3.3 (a)/(b)/(c), L646-652 — each filter alone
// excludes; all-clear is eligible.
const Case = struct { e: Eligibility, want: bool };
const cases = [_]Case{
.{ .e = elig_clear, .want = true },
.{ .e = .{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false }, .want = false },
.{ .e = .{ .peer_lacks = false, .shunned = true, .private = false, .private_sync_enabled = false }, .want = false },
.{ .e = .{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false }, .want = false },
.{ .e = .{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true }, .want = true },
};
for (cases) |c| try std.testing.expectEqual(c.want, gimmeEligible(c.e));
}
test "sync §9.4: per-round filters are not persisted across rounds" {
// §9.4 L2337-2338 + §3.1 r20/r21 — a round boundary resets both tables.
var rt = RoundTables.init(std.testing.allocator);
defer rt.deinit();
try rt.recordPeerLacks(test_hash_a);
try rt.recordPeerHas(test_hash_b);
rt.reset();
try std.testing.expect(!rt.peerLacks(test_hash_a));
try std.testing.expect(!rt.peerHas(test_hash_b));
}
test "sync §9.4: peer-lacks hashes are excluded from gimme (rejection)" {
// §3.3 filter (a), L647.
try std.testing.expect(!gimmeEligible(.{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false }));
}
test "sync §9.4: shunned hashes are excluded from gimme (rejection)" {
// §3.3 filter (b), L649.
try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = true, .private = false, .private_sync_enabled = false }));
}
test "sync §9.4: private hashes excluded unless private sync enabled (rejection)" {
// §3.3 filter (c), L651-652.
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 §9.4: the eligibility filter is evaluated freshly each round" {
// §3.3 L644-645 — "evaluated freshly each round." Record peer-lacks(H)
// this round -> excluded; reset the round -> eligible again.
var rt = RoundTables.init(std.testing.allocator);
defer rt.deinit();
try rt.recordPeerLacks(test_hash_a);
try std.testing.expect(!gimmeEligible(.{ .peer_lacks = rt.peerLacks(test_hash_a), .shunned = false, .private = false, .private_sync_enabled = false }));
rt.reset();
try std.testing.expect(!rt.peerLacks(test_hash_a));
try std.testing.expect(gimmeEligible(.{ .peer_lacks = rt.peerLacks(test_hash_a), .shunned = false, .private = false, .private_sync_enabled = false }));
}
test "sync §9.4: receiving gimme records the peer lacks the hash" {
// §3.3 L654-655 — a receiver that gets gimme HASH records peer-lacks 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(!rt.peerLacks(test_hash_b));
}
test "sync §9.4: gimme for unheld content is silently skipped (rejection)" {
// §3.3 L661-662 — unheld gimme is a silent skip, no error card.
try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, true, true));
try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, false, false));
}
test "sync §9.4: gimme for held-but-unsendable content emits nothing, no defer (rejection)" {
// §3.3 L658-660 — held but the direction forbids sending: no content, no
// error card, no deferred send.
try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, true));
try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, false));
}
test "sync §9.5: loop termination follows §3.7 including clone continuation (e)" {
// §9.5 L2341-2344 — termination is the §3.7 rule, clone included.
const Case = struct { obs: RoundObservation, want: bool };
const cases = [_]Case{
.{ .obs = .{ .operation = .clone, .first_clone_round = true }, .want = true }, // (e)
.{ .obs = .{ .operation = .clone, .artifact_received_this_round = true }, .want = true }, // (e)
.{ .obs = .{ .operation = .clone, .clone_seqno_next = 5, .artifact_count_grew = true }, .want = true }, // (e)
.{ .obs = .{ .operation = .clone }, .want = false }, // clone, none of (e) -> stop
.{ .obs = .{ .operation = .pull, .versioned_content_delivered = true, .phantom_table_nonempty = true }, .want = true }, // (a)
};
for (cases) |c| try std.testing.expectEqual(c.want, continueExchange(c.obs));
}
test "sync §9.5: termination reads the per-round content-received flag, not per-artifact phantom state (rejection)" {
// §3.7 L775-778 + §9.5 — the client MUST NOT continue solely because
// phantoms remain. Continuation is driven by the per-round content-
// received flag (`versioned_content_delivered`, set by BOTH the normal
// and the dangling-delta path per §3.3 L677-680), never per-artifact
// store state. `continueExchange` takes only a `RoundObservation`, so it
// structurally cannot consult the phantom set.
const stalled = RoundObservation{ .operation = .pull, .phantom_table_nonempty = true };
try std.testing.expect(!continueExchange(stalled)); // unresolved phantoms alone -> stop
const progressed = RoundObservation{ .operation = .pull, .versioned_content_delivered = true, .phantom_table_nonempty = true };
try std.testing.expect(continueExchange(progressed)); // (a) via the per-round flag
}
test "sync §9.5: the server signals no completion and holds no per-session termination state" {
// §9.5 L2344-2347 + §3.7 L789-791 — the reply's closing comment carries
// only a timestamp and an error count; termination is decided solely by
// the client's `continueExchange` over its own per-round observation.
const tc = parseTimestampComment("# timestamp 2026-07-10T00:00:00 errors 0").?;
try std.testing.expectEqualStrings("2026-07-10T00:00:00", tc.datetime);
try std.testing.expectEqual(@as(u64, 0), tc.errors);
// TimestampComment carries exactly {datetime, errors} — no completion field.
try std.testing.expectEqual(@as(usize, 2), @typeInfo(TimestampComment).@"struct".fields.len);
// With no pending work the client stops on its own, no server input.
try std.testing.expect(!continueExchange(.{ .operation = .sync }));
}
// ---------------------------------------------------------------------------
// Section 3.2 — Reply consumption (Client.consumeReply); Appendix A worked
// exchanges A.1 (push/pull convergence), A.2 (SHA3 gate error card), A.4
// (comment lines); §7.5 (private-marker state machine, r1-r8).
// ---------------------------------------------------------------------------
// §A.1 L3085-3090 — Round 1 reply, 367-byte body: server-version pragma, two
// gimme cards, two igot cards, closing timestamp. The vector holds raw wire
// bytes; the citation lives here, never inside the .txt.
const a1_round1_reply = @embedFile("rfc_vectors/sync_a1_round1_reply.txt");
// §A.1 L3141-3152 — Round 2 reply, 533-byte body: server-version pragma, two
// full-content file cards (270- and 31-byte payloads), closing timestamp.
const a1_round2_reply = @embedFile("rfc_vectors/sync_a1_round2_reply.txt");
// §A.2 L3206-3209 — SHA3 gate response, 191-byte body: error, clone_seqno 0,
// push, closing timestamp.
const a2_reply = @embedFile("rfc_vectors/sync_a2_reply.txt");
comptime {
// The RFC states these body sizes directly (L3074, L3126, L3204); a
// transcription drift trips here before any test runs.
std.debug.assert(a1_round1_reply.len == 367);
std.debug.assert(a1_round2_reply.len == 533);
std.debug.assert(a2_reply.len == 191);
}
// §A.1 hashes: server gimme -> peer-lacks (L3086-3087); the ancestor the
// server already holds (L3092).
const a1_gimme_1 = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
const a1_gimme_2 = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15";
const a1_held_ancestor = "c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57";
test "sync §A.1: round-1 reply creates two phantoms from the server igot cards" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
// L3093-3094 — two new phantoms 4361252d…/f5116517…, keyed by interned rid.
try std.testing.expectEqual(@as(usize, 2), client.phantoms.count());
try std.testing.expect(obs.phantom_created);
const rid1 = try ms.store().intern(a1_phantom_1);
const rid2 = try ms.store().intern(a1_phantom_2);
try std.testing.expect(client.phantoms.contains(rid1));
try std.testing.expect(client.phantoms.contains(rid2));
}
test "sync §A.1: server does not gimme the held ancestor; gimme records peer-lacks" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// L4099-4100 — the client announced igot for the two gimme'd hashes, so it
// holds them; pre-store so the round-1 gimme cards queue them for upload.
const rid1 = try ms.store().put(a1_gimme_1, "manifest payload\n");
const rid2 = try ms.store().put(a1_gimme_2, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
// (b) — the two server gimme cards (L4108-4109) queue outbound content.
try std.testing.expect(obs.content_queued);
try std.testing.expectEqual(@as(usize, 2), client.send_queue.count());
try std.testing.expect(client.send_queue.contains(rid1));
try std.testing.expect(client.send_queue.contains(rid2));
// The exact peer-lacks set is the two gimme'd hashes; the ancestor
// c5b2e0ec… is absent because the server already holds it (L4114) and so
// never gimme's it back — igotDisposition ignores an already-held hash.
var rt = RoundTables.init(std.testing.allocator);
defer rt.deinit();
try rt.recordPeerLacks(a1_gimme_1);
try rt.recordPeerLacks(a1_gimme_2);
try std.testing.expect(rt.peerLacks(a1_gimme_1));
try std.testing.expect(rt.peerLacks(a1_gimme_2));
try std.testing.expect(!rt.peerLacks(a1_held_ancestor));
try std.testing.expectEqual(IgotDisposition.ignore, igotDisposition(.client, .sync, true));
}
test "sync §A.1: both §3.7 conditions hold after round 1, exchange continues" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// Client holds the two gimme'd hashes (L4099-4100), so (b) fires.
_ = try ms.store().put(a1_gimme_1, "manifest payload\n");
_ = try ms.store().put(a1_gimme_2, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
try std.testing.expect(obs.phantom_table_nonempty and obs.phantom_created); // (a)
try std.testing.expect(obs.content_queued); // (b)
try std.testing.expect(continueExchange(obs));
try std.testing.expectEqual(ClientState.exchange, client.state);
}
test "sync §A.1: round-2 delivers two full-content files and empties the phantom set" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply);
// Both file cards are full-content (two-argument) form (L3123-3124); the
// stated payload sizes are 270 (L3142) and 31 (L3150).
const rid1 = try ms.store().intern(a1_phantom_1);
const rid2 = try ms.store().intern(a1_phantom_2);
try std.testing.expectEqual(@as(usize, 270), ms.store().content(rid1).?.len);
try std.testing.expectEqual(@as(usize, 31), ms.store().content(rid2).?.len);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(obs.versioned_content_delivered and !obs.phantom_created);
}
test "sync §A.1: no round 3 — the client terminates after round 2" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// Round 1 queues the two held gimmes; round 2's reply carries no gimme.
_ = try ms.store().put(a1_gimme_1, "manifest payload\n");
_ = try ms.store().put(a1_gimme_2, "alpha content from client\n");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply);
// L4179-4182 — round 2 delivered content but its reply had no gimme, so
// nothing was appended this round: the per-round file-send count is zero.
try std.testing.expect(!obs.content_queued);
// The per-session buffer still holds the two rids queued in round 1 (§3.1
// L605-608), yet §3.7(b) reads the per-round count, not the buffer.
try std.testing.expectEqual(@as(usize, 2), client.send_queue.count());
// §3.7 holds nowhere prospectively; the client stops.
try std.testing.expect(!continueExchange(obs));
try std.testing.expectEqual(ClientState.terminated, client.state);
}
test "sync §A.2: clone reply discards the error, records seqno and push, advances" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(std.testing.allocator);
try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a2_reply);
// L3217 — clone_seqno 0 = exhausted RID range.
try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next);
// L3208, §8.3/§11.1 — the project code arrives in the push card.
const push = parsePushPull(tokenizeCard("push 34302acad64fe2397bd907704dec457899e2f9a3 c85e3efddf993e86e8325d08672ea94bdba6c2c4")).?;
try std.testing.expectEqualStrings("c85e3efddf993e86e8325d08672ea94bdba6c2c4", push.projectcode);
// r14 — a processed first clone reply advances to EXCHANGE, not aborted
// (L3221-3224: the round-0 error is discarded, not fatal).
try std.testing.expectEqual(ClientState.exchange, client.state);
}
test "sync §A.2: cards after the discarded error are still dispatched (non-reset)" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), a2_reply);
// The closing timestamp line (L3209) follows error/clone_seqno/push; a
// stop at the error card would leave it unseen (L3212-3216, no reset).
try std.testing.expect(client.last_timestamp != null);
try std.testing.expectEqualStrings("2026-07-02T18:26:49", client.last_timestamp.?.datetime);
try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next);
}
test "sync §A.4: both server replies surface the closing timestamp comment" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply);
// L3439 — round-1 reply closes with the timestamp subform.
try std.testing.expectEqualStrings("2026-07-02T18:31:13", client.last_timestamp.?.datetime);
try std.testing.expectEqual(@as(u64, 0), client.last_timestamp.?.errors);
_ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply);
// L3443 — round-2 reply closes with the same subform, N = 0.
try std.testing.expectEqualStrings("2026-07-02T18:31:13", client.last_timestamp.?.datetime);
try std.testing.expectEqual(@as(u64, 0), client.last_timestamp.?.errors);
}
fn consumeReplyOom(gpa: std.mem.Allocator) !void {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
// Round 1 fills the phantom set (two igot) and peer-lacks (two gimme); no
// card in it is fatal or unimplemented, so OOM is the only non-success.
_ = client.consumeReply(gpa, ms.store(), a1_round1_reply) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.FatalProtocolError, error.NotImplemented => unreachable,
};
}
test "sync §3.2: consumeReply covers OOM paths (phantom set + peer-lacks fill)" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeReplyOom, .{});
}
// §7.5 — an arbitrary 64-hex hash for the private-marker tests. No expected
// byte derives from it; the store keys under the literal name and the asserted
// value (private_content_delivered) comes from the r1/r4 rules alone.
const p75_hash = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0";
test "sync §7.5 r1: a private card marks the next file card as private" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// §7.5 L2381-2385 — with the grant present the bound content is stored, so
// `private_content_delivered` is the observable proof the marker was bound.
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(std.testing.allocator);
// L2359-2360 — "A private card … marks the next … file or cfile card as
// carrying private content."
const body = "private\nfile " ++ p75_hash ++ " 1\nx";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!client.private_pending); // r4 — consumed by the file card
}
test "sync §7.5 r2: intervening comment/non-content cards do not consume the marker" {
// L2361-2362 — "intervening comment lines and other non-content cards do
// not consume it."
const intervening = [_][]const u8{ "# a comment\n", "pragma foo bar\n" };
for (intervening) |line| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = try std.mem.concat(std.testing.allocator, u8, &.{ "private\n", line });
defer std.testing.allocator.free(body);
_ = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(client.private_pending); // still set — nothing consumed it
}
}
test "sync §7.5 r3: a repeated private card leaves the marker set" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L2362-2363 — "a repeated private card leaves the flag set."
_ = try client.consumeReply(std.testing.allocator, ms.store(), "private\nprivate\n");
try std.testing.expect(client.private_pending);
}
test "sync §7.5 r4: the marker is consumed when the next cfile card handling begins" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// §7.5 L2381-2385 — grant present so the bound cfile is stored (delivered).
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(std.testing.allocator);
// Build the §4.1 container in-test (uncompressed size 1 for the byte "y").
const container = try zlibContainerEncode(std.testing.allocator, "y", .default);
defer std.testing.allocator.free(container);
const header = try std.fmt.allocPrint(std.testing.allocator, "private\ncfile {s} 1 {d}\n", .{ p75_hash, container.len });
defer std.testing.allocator.free(header);
const body = try std.mem.concat(std.testing.allocator, u8, &.{ header, container });
defer std.testing.allocator.free(body);
// L2363-2364 — "the flag is consumed only when handling of the next file or
// cfile card begins."
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!client.private_pending);
}
test "sync §7.5 r5: the pending marker persists across reply bodies in a session" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// §7.5 L2381-2385 — grant present so body 2's file card delivers (stores).
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(std.testing.allocator);
// L2364-2366 — "A client receiver carries the pending marker across
// successive reply bodies within a single sync session." Body 1 keeps the
// round alive so a 2nd reply is legal: the igot creates a phantom (§3.7(a)
// → round_continues → state stays .exchange), and being a non-content card
// it does not consume the marker (r2).
const body1 = "private\nigot " ++ a1_phantom_1 ++ "\n";
_ = try client.consumeReply(std.testing.allocator, ms.store(), body1);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(client.private_pending); // carried into the next body
// Body 2 delivers the announced artifact; that file card consumes the marker.
const body2 = "file " ++ a1_phantom_1 ++ " 1\nx";
const obs2 = try client.consumeReply(std.testing.allocator, ms.store(), body2);
try std.testing.expect(obs2.private_content_delivered);
try std.testing.expect(!client.private_pending);
}
test "sync §7.5 r6: a pending marker does not carry across the session boundary" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// L2366-2367 — "only a subsequent file or cfile card, or the end of that
// session, clears it." A fresh Client (new session) starts clear; a marker
// left pending in a prior session never binds a content card in a new one.
var c1 = Client{ .operation = .sync, .state = .exchange };
_ = try c1.consumeReply(std.testing.allocator, ms.store(), "private\n");
try std.testing.expect(c1.private_pending);
try std.testing.expectEqual(ClientState.terminated, c1.state);
c1.deinit(std.testing.allocator);
var c2 = Client{ .operation = .sync, .state = .exchange };
defer c2.deinit(std.testing.allocator);
try std.testing.expect(!c2.private_pending); // new session starts clear
const body = "file " ++ p75_hash ++ " 1\nx";
const obs = try c2.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(!obs.private_content_delivered); // no leak from session 1
}
test "sync §7.5 r7: a reply body ending with the marker still pending is not fatal (rejection)" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L2369-2371 — "Reaching the end of a client reply body with the marker
// still pending is not by itself a fatal protocol error." The body returns
// an observation (no error) and ends in the ordinary .terminated round end,
// not .aborted.
_ = try client.consumeReply(std.testing.allocator, ms.store(), "private\n# c\n");
try std.testing.expect(client.private_pending);
try std.testing.expectEqual(ClientState.terminated, client.state);
}
test "sync §7.5 r8: a private card carrying an argument is a fatal card-arity abort (rejection)" {
// r8 — "a private card carrying any argument is a §5.2 card-arity violation
// and a fatal strict-accept abort (§3.9)." ABNF L4643 fixes arity 1; §5.2
// L1770-1776 (defined operator + bad arg count → its own arity rule) and
// L1809-1813 (the client aborts the round, no outbound error card).
const bad = [_][]const u8{ "private x\n", "private a b\n" };
for (bad) |body| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expect(!client.private_pending); // never set on the arity-fail path
}
}
const a1_phantom_1 = "4361252d0f450879dcd2e0b6a42b95ffe8a41d8e948130253ae8eefa7f4b79ea";
const a1_phantom_2 = "f5116517775075ea483901021282ba97eebe5a2b993a47eb78198adc10efe128";
test "sync §A.4: a client nonce comment is skipped with no store or obs effect" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// L3425, L3431-3433 — a `#`-prefixed 40-hex nonce is not the timestamp
// subform, so it is skipped silently.
const body = "# F2A895EF75B47B7157664A7FEAC978EFFFDB01C4\n";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(!obs.versioned_content_delivered and !obs.phantom_created);
try std.testing.expect(client.last_timestamp == null);
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); // store untouched
}
test "sync §3.3 r24: an igot for an already-held artifact creates no phantom" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
_ = try ms.store().put(a1_phantom_1, "held content");
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "igot " ++ a1_phantom_1 ++ "\n";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(!obs.phantom_created);
}
test "sync §3.3: a repeated igot of one hash counts a single phantom" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "igot " ++ a1_phantom_1 ++ "\nigot " ++ a1_phantom_1 ++ "\n";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.phantom_created);
try std.testing.expectEqual(@as(usize, 1), client.phantoms.count());
}
test "sync §3.3 r27: a malformed gimme hash is fatal at parse, never asserted downstream" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "gimme deadbeef00\n"; // 10 hex — not a 40/64 hash token
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §3.7(a): content for a never-phantom'd hash does not underflow the set" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// File for an unseen hash: the remove is a no-op, no trap under ReleaseSafe.
const body = "file " ++ a1_phantom_1 ++ " 3\nabc";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expect(!obs.phantom_table_nonempty);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
}
test "sync §3.8 r19: a negative wire clone_seqno is fatal at parse, never stored" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(std.testing.allocator);
const body = "clone_seqno -1\n";
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §3.9: a clone card in a reply is a fatal protocol violation, not NotImplemented" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "clone 3 1\n";
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); // store unchanged
}
test "sync §5: an out-of-scope legal reply card is a strict boundary, not a crash" {
const bodies = [_][]const u8{
"uvfile a.txt 1700000000 " ++ test_hash_a ++ " 12 4\n", // §7.3 uvfile, omitted content, still staged
"file " ++ a1_phantom_1 ++ " " ++ a1_phantom_2 ++ " 5\nHELLO", // delta-form (arity 4)
};
for (bodies) |body| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); // store unchanged
}
}
test "sync §3.7(a): an igot then a file for the same hash dephantomizes within the round" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
// A reply that announces then delivers one hash: the igot creates the
// phantom, the file in the same body removes it. phantom_created stays set
// (it happened) while the table ends empty — the two are independent, so
// phantom_table_nonempty is the authority for §3.7(a), never phantom_created.
const body = "igot " ++ a1_phantom_1 ++ "\nfile " ++ a1_phantom_1 ++ " 3\nabc";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.phantom_created);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(!obs.phantom_table_nonempty);
}
// ---------------------------------------------------------------------------
// §3.6 / §6.1 / §10.3 — reply-dispatch wiring: cookie record, login/reqconfig
// strict-accept aborts, config framing consume.
// ---------------------------------------------------------------------------
test "sync §3.6: consumeReply stores a cookie into the held CookieStore" {
// §3.6 L913-914 — "The client that receives such a card MUST store the
// value"; TEXT is opaque with no escapes, so it stores verbatim.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), "cookie s3cr3t\n");
try std.testing.expectEqualStrings("s3cr3t", client.cookie.value().?);
try std.testing.expect(obs.cookie_stored);
}
test "sync §3.6: a second cookie overwrites the first" {
// §3.6 L914-915 — "replacing any previously held cookie for that server".
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), "cookie v1\ncookie v2\n");
try std.testing.expectEqualStrings("v2", client.cookie.value().?);
}
test "sync §3.6: multiple cookie cards in one reply leave the last, none rejected" {
// §3.6 L920-921 — "must be prepared to receive more than one cookie card in
// a single request": each is accepted, the last wins, none aborts.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), "cookie one\ncookie two\ncookie three\n");
try std.testing.expectEqualStrings("three", client.cookie.value().?);
try std.testing.expect(client.state != .aborted);
}
test "sync §3.6/§4.3: cookie TEXT is defossilized before storing" {
// §4.3 — cookie TEXT is fossilize-encoded like error/message TEXT; the wire
// token `a\sb` decodes to `a b` (the inverse of writeCookieCard("a b")).
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("cookie a\\sb", writeCookieCard("a b", &buf));
_ = try client.consumeReply(std.testing.allocator, ms.store(), "cookie a\\sb\n");
try std.testing.expectEqualStrings("a b", client.cookie.value().?);
}
test "sync §3.9: a cookie card never aborts, reports a non-fatal observation" {
// §3.9 L1312 — "A cookie card from an unexpected server is tolerated": no
// abort, a non-fatal round observation, the state machine stays live.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const obs = try client.consumeReply(std.testing.allocator, ms.store(), "cookie x\n");
try std.testing.expect(obs.cookie_stored);
try std.testing.expect(client.state != .aborted);
}
test "sync §5.2/§B.4: a cookie card with no TEXT token is a fatal arity abort (rejection)" {
// §B.4 L4622 `cookie-card = "cookie" SP fossilized LF` — exactly two tokens.
// A bare `cookie\n` (arity 1) fails the card's argument check; §5.2 L1809-1813
// makes the client record a local warning and abort the round. §3.9's cookie
// tolerance (L1312) is scoped to an unexpected *server*, not a missing TEXT.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "cookie\n"));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expect(client.cookie.value() == null);
}
test "sync §3.6/§5.1: a cookie card carries no byte payload; dispatch resumes at the next line" {
// §5.1 — cookie has no byte-counted payload, so the cursor resumes at the
// next line offset. The file card after it parses and delivers content,
// proving the resume point was payload_start, not past any bytes.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "cookie tok\nfile " ++ a1_phantom_1 ++ " 3\nabc";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expectEqualStrings("tok", client.cookie.value().?);
try std.testing.expect(obs.versioned_content_delivered);
}
test "sync §6.1/§3.9: a login card in a server reply is a fatal protocol violation (rejection)" {
// §6.1 L1919-1920 — login is a request-only card ("carries exactly one
// login card"); in a server reply it is a strict-accept abort (§3.9).
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "login alice deadbeef cafef00d\n";
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len);
}
test "sync §3.8 r15/r18: login-in-reply aborts from both reply-bearing states" {
// §3.8 Table 3 — the abort routes through Client.abort: `fatal_error` from
// exchange, `clone_fatal` from clone_round0; both land in `aborted`.
const cases = [_]struct { op: Operation, state: ClientState }{
.{ .op = .sync, .state = .exchange },
.{ .op = .clone, .state = .clone_round0 },
};
for (cases) |c| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = c.op, .state = c.state };
defer client.deinit(std.testing.allocator);
const body = "login alice deadbeef cafef00d\n";
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
}
test "sync §10.3/§3.9: a reqconfig card in a server reply is a fatal abort (rejection)" {
// §10.3 L3272 — "A client sends reqconfig NAME": reqconfig is client→server
// only; the reverse flow is a config push, not reqconfig. A reqconfig in a
// server reply is a strict-accept abort (§3.9).
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "reqconfig /ui\n"));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len);
}
test "sync §10.3/§5.1: consumeReply frames a config payload by its SIZE byte count" {
// §10.3 L3278 — `config GROUPNAME SIZE` then SIZE content bytes. The payload
// embeds an LF (`A\nBCD` = 5 bytes); byte-count framing (never scanning)
// means the embedded LF is not a card boundary and the trailing cookie
// still dispatches.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), "config /config 5\nA\nBCD\ncookie z\n");
try std.testing.expectEqualStrings("z", client.cookie.value().?);
}
test "sync §10.3: a config card's trailing newline is consumed" {
// §10.3 L3320-3322 — "A single newline byte follows the content payload ...
// receivers MUST consume this trailing newline". The cookie dispatches only
// if the LF after `abc` was consumed.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = try client.consumeReply(std.testing.allocator, ms.store(), "config /config 3\nabc\ncookie z\n");
try std.testing.expectEqualStrings("z", client.cookie.value().?);
}
test "sync §10.3: dispatch resumes at start + SIZE + 1 after a config card" {
// §10.3 — after SIZE payload bytes and the mandatory trailing LF, the next
// card begins at start + SIZE + 1. A following file card delivers content
// only if the resume offset is exact (off-by-one would misparse it).
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
const body = "config /config 3\nabc\nfile " ++ a1_phantom_1 ++ " 3\nXYZ";
const obs = try client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
}
test "sync §10.3/§7: a config frame past the reply body is a fatal abort (rejection)" {
// §10.3/§3.9 — SIZE 10 but only 4 bytes follow: ConfigFrameError.Truncated
// routes through Client.abort to a fatal protocol error.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "config /config 10\nabc\n"));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len);
}
test "sync §10.3: a config card missing the trailing newline is a fatal abort (rejection)" {
// §10.3 L3320-3322 — the byte at start+SIZE is `X`, not the mandated LF:
// ConfigFrameError.MissingNewline routes through Client.abort.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "config /config 3\nabcX\n"));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §10.3: a malformed config header is a fatal abort (rejection)" {
// §10.3 L3278 `config GROUPNAME SIZE` — each malformed header is a fatal
// abort: (a) too few tokens, (b) non-numeric SIZE, (c) SIZE > u32,
// (d) negative SIZE (§10.3 L3331: "SIZE is negative is a fatal protocol
// error"). parseConfigCard yields Malformed / NegativeSize, both abort.
const headers = [_][]const u8{
"config /config\n", // (a) no SIZE
"config /config xy\n", // (b) non-numeric SIZE
"config /config 4294967296\n", // (c) 2^32, past u32
"config /config -1\n", // (d) negative SIZE
};
for (headers) |body| {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len);
}
}
test "sync §3.6/§4.3 fuzz: a well-formed cookie card round-trips through consumeReply" {
// Invariant (§3.6 L913-914, §4.3): for any non-empty opaque value x,
// consumeReply of `cookie <fossilize(x)>` stores exactly x and never
// aborts. Fixed seed for exact replay.
var prng = std.Random.DefaultPrng.init(0xC0_0C_1E_FA);
const rand = prng.random();
var raw: [48]u8 = undefined;
var token: [96]u8 = undefined;
var card: [128]u8 = undefined;
var round: usize = 0;
while (round < 512) : (round += 1) {
// §B.4 L4622/§5.2 — interleave the arity-reject shape: a `cookie` line
// whose only trailing content is a whitespace run tokenizes to arity 1,
// which the client MUST reject as fatal and store nothing, whatever the
// run length.
if (rand.boolean()) {
const bp = "cookie";
@memcpy(card[0..bp.len], bp);
const ws = rand.intRangeAtMost(usize, 0, 8);
for (card[bp.len..][0..ws]) |*b| b.* = if (rand.boolean()) ' ' else '\t';
card[bp.len + ws] = '\n';
const bad = card[0 .. bp.len + ws + 1];
var ms2 = repostate.MemStore.init(std.testing.allocator);
defer ms2.deinit();
var bad_client = Client{ .operation = .sync, .state = .exchange };
defer bad_client.deinit(std.testing.allocator);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, bad_client.consumeReply(std.testing.allocator, ms2.store(), bad));
try std.testing.expectEqual(ClientState.aborted, bad_client.state);
try std.testing.expect(bad_client.cookie.value() == null);
continue;
}
const n = rand.intRangeAtMost(usize, 1, raw.len); // non-empty: empty has no TEXT token
for (raw[0..n]) |*b| b.* = rand.int(u8); // any byte: LF/NUL/SP all legal in x
const enc = artifact.encodeEscapes(raw[0..n], &token); // whitespace-free wire token
const prefix = "cookie ";
@memcpy(card[0..prefix.len], prefix);
@memcpy(card[prefix.len..][0..enc.len], enc);
card[prefix.len + enc.len] = '\n';
const body = card[0 .. prefix.len + enc.len + 1];
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| {
try std.testing.expect(e == error.OutOfMemory);
continue;
};
try std.testing.expect(client.state != .aborted);
try std.testing.expectEqualStrings(raw[0..n], client.cookie.value().?);
}
}
test "sync §10.3 fuzz: config byte-count framing survives an adversarial payload" {
// Invariant (§10.3 L3278/L3320-3322): a config card frames its payload by
// SIZE alone, so a trailing cookie always dispatches regardless of payload
// bytes — embedded LF/NUL never fool the framer into an early boundary.
var prng = std.Random.DefaultPrng.init(0xC0_F1_6F_1E);
const rand = prng.random();
var payload: [64]u8 = undefined;
var card: [160]u8 = undefined;
var round: usize = 0;
while (round < 512) : (round += 1) {
const size = rand.uintLessThan(usize, payload.len + 1);
for (payload[0..size]) |*b| b.* = rand.int(u8); // may embed LF/NUL
const header = std.fmt.bufPrint(&card, "config /config {d}\n", .{size}) catch unreachable;
var w = header.len;
@memcpy(card[w..][0..size], payload[0..size]);
w += size;
card[w] = '\n'; // mandatory trailing LF
w += 1;
const suffix = "cookie mark\n";
@memcpy(card[w..][0..suffix.len], suffix);
w += suffix.len;
const body = card[0..w];
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(std.testing.allocator);
_ = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| {
try std.testing.expect(e == error.OutOfMemory);
continue;
};
try std.testing.expect(client.state != .aborted);
try std.testing.expectEqualStrings("mark", client.cookie.value().?);
}
}
// ---------------------------------------------------------------------------
// §7.2 — cfile card (full-content) client reply-dispatch through consumeReply.
// ---------------------------------------------------------------------------
/// Build `cfile HASH USIZE CSIZE\n` followed by `payload` bytes. USIZE and
/// CSIZE are caller-supplied so a rejection test can frame a deliberate lie
/// (a CSIZE past the body, a USIZE that disagrees with the decoded length).
/// Owned by the caller.
fn buildCfileBody(gpa: std.mem.Allocator, hash: []const u8, usize_field: usize, csize_field: usize, payload: []const u8) ![]u8 {
const header = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ hash, usize_field, csize_field });
defer gpa.free(header);
const body = try gpa.alloc(u8, header.len + payload.len);
@memcpy(body[0..header.len], header);
@memcpy(body[header.len..], payload);
return body;
}
test "sync §7.2-r6: consumeReply stores a full-content cfile's decompressed bytes under HASH" {
const gpa = std.testing.allocator;
const plaintext = "cfile artifact payload\n";
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
// §7.2-r1/r5 — USIZE is the reconstructed length (syntax only); CSIZE is
// the wire byte count that actually frames the payload.
const body = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered); // §3.7(a) versioned progress
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); // §7.2-r6 decompressed bytes under HASH
}
test "sync §7.2-r7: an igot then a full-content cfile for the same hash dephantomizes" {
const gpa = std.testing.allocator;
const plaintext = "dephantomized by cfile";
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(cfile);
// §3.7(a)/§3.3 r36 — the igot creates the phantom, the cfile in the same
// body removes it exactly as a full-content file card would (r7).
const body = try std.fmt.allocPrint(gpa, "igot {s}\n{s}", .{ test_hash_a, cfile });
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.phantom_created);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(!obs.phantom_table_nonempty);
}
test "sync §7.2-r9: a full-content cfile payload is container-decoded exactly once" {
const gpa = std.testing.allocator;
// §7.2-r9 — the x-fossil-uncompressed outer body is already decoded, so the
// cfile payload is container-decoded once. This plaintext is itself NOT a
// §4.1 container: a spurious second decode pass would abort, so a clean
// store of these exact bytes proves the single-decode contract.
const plaintext = "plain artifact bytes, not a nested container";
if (zlibContainerDecode(gpa, plaintext, std.math.maxInt(usize))) |leaked| {
gpa.free(leaked);
try std.testing.expect(false); // guard: the plaintext must not itself be a valid container
} else |_| {}
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
const body = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expect(client.state != .aborted);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?);
}
test "sync §7.2-r14: a full-content cfile consumed in clone_round0 counts as clone content progress" {
const gpa = std.testing.allocator;
const plaintext = "clone content via cfile";
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
const body = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered); // §8.2 r14 — identical to a full file card
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expectEqual(ClientState.exchange, client.state); // §3.8 r14 unconditional advance
}
test "sync §7.2-r15: a Version-3 clone reply carrying a full cfile stores under HASH and clears the phantom" {
const gpa = std.testing.allocator;
// §8.1 Version 3 / §B.4 cfile-transfer — igot announces, cfile delivers,
// clone_seqno closes the round. End-to-end: content stored, phantom gone.
const plaintext = "version 3 clone artifact";
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(cfile);
const body = try std.fmt.allocPrint(gpa, "igot {s}\n{s}clone_seqno 0\n", .{ test_hash_a, cfile });
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(client.state != .aborted);
}
test "sync §7.2-r8: a full-content cfile is stored without a pre-storage hash recompute (transfer layer)" {
const gpa = std.testing.allocator;
// §7.7 — pre-storage integrity does not apply to cfile in the transfer
// layer: the plaintext deliberately does NOT hash to HASH, yet it stores.
const plaintext = "content whose sha does not match the card name";
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
const body = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expect(client.state != .aborted);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?);
}
test "sync §7.2-r10: a malformed cfile header is a fatal client protocol error (rejection)" {
const gpa = std.testing.allocator;
const cases = [_][]const u8{
"cfile " ++ test_hash_a ++ " 10\n", // arity 3 — too few tokens
"cfile " ++ test_hash_a ++ " x 5\nHELLO", // non-numeric USIZE
"cfile " ++ test_hash_a ++ " 5 x\nHELLO", // non-numeric CSIZE
};
for (cases) |body| {
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state); // §3.9 strict-accept, §5.2 client fatal
}
}
test "sync §7.2-r11: a cfile whose CSIZE span runs past the reply body is a fatal content error (rejection)" {
const gpa = std.testing.allocator;
// CSIZE 100 but only 5 payload bytes follow → ContentFrameError.Truncated.
const body = "cfile " ++ test_hash_a ++ " 5 100\nshort";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
test "sync §7.2-r12: a cfile payload that is not a valid fossil zlib container is a fatal content error (rejection)" {
const gpa = std.testing.allocator;
// TruncatedHeader, OutputTooLarge, InvalidStream — each folds to §3.9 fatal.
const payloads = [_][]const u8{
"\x00\x01", // short header (<4 bytes) → TruncatedHeader
"\xff\xff\xff\xff\x00", // declared length » reply_body_cap → OutputTooLarge
"\x00\x00\x00\x04\x78\x9c\xff\xff", // 4-byte length + corrupt RFC1950 stream → InvalidStream
};
for (payloads) |p| {
const body = try buildCfileBody(gpa, test_hash_a, 4, p.len, p);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
}
test "sync §7.2-r13: the four-argument delta cfile form defers with NotImplemented (rejection)" {
const gpa = std.testing.allocator;
// cfile HASH DELTASRC USIZE CSIZE (5 tokens) needs the §7.4 retained-delta
// seam and is staged — NotImplemented, never a silent accept, never fatal.
const payload = "x" ** 40;
const body = try std.fmt.allocPrint(gpa, "cfile {s} {s} 100 {d}\n{s}", .{ test_hash_a, test_hash_b, payload.len, payload });
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), body));
try std.testing.expect(client.state != .aborted); // staged, not a fatal abort
}
fn consumeCfileStoresNoLeak(gpa: std.mem.Allocator) !void {
// The store lives on the base allocator so only consumeReply's decode
// buffer and deinit see the failing allocator (mirrors consumeGimme...).
const plaintext = "cfile allocation-path payload";
const enc = try zlibContainerEncode(std.testing.allocator, plaintext, .default);
defer std.testing.allocator.free(enc);
const body = try buildCfileBody(std.testing.allocator, test_hash_a, plaintext.len, enc.len, enc);
defer std.testing.allocator.free(body);
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
}
test "sync §7.2-r6: consumeReply stores a full-content cfile under allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeCfileStoresNoLeak, .{});
}
test "sync §7.2 fuzz: cfile framing+container decode round-trips and never crashes" {
// Invariant (§7.2-r2/r3/r5/r6): a full-content cfile framed by CSIZE and
// carrying a §4.1 container of x stores exactly x and never aborts;
// adversarial CSIZE/payload bytes only ever yield a declared error, never
// a crash (memory-safe under ReleaseSafe). Fixed seed for exact replay.
const gpa = std.testing.allocator;
var prng = std.Random.DefaultPrng.init(0xCF_11_E7_2C);
const rand = prng.random();
var raw: [96]u8 = undefined;
var round: usize = 0;
while (round < 512) : (round += 1) {
if (rand.boolean()) {
// Well-formed branch: a real container round-trips exactly.
const n = rand.intRangeAtMost(usize, 0, raw.len);
for (raw[0..n]) |*b| b.* = rand.int(u8);
const enc = try zlibContainerEncode(gpa, raw[0..n], .default);
defer gpa.free(enc);
const body = try buildCfileBody(gpa, test_hash_a, n, enc.len, enc);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
_ = client.consumeReply(gpa, ms.store(), body) catch |e| {
try std.testing.expect(e == error.OutOfMemory);
continue;
};
try std.testing.expect(client.state != .aborted);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(raw[0..n], ms.store().content(rid).?);
} else {
// Adversarial branch: random CSIZE and random payload. The only
// outcomes are a declared error (fatal/NotImplemented/OOM) or a
// clean parse — never a crash.
const plen = rand.intRangeAtMost(usize, 0, raw.len);
for (raw[0..plen]) |*b| b.* = rand.int(u8);
const csize = rand.intRangeAtMost(usize, 0, raw.len + 8); // may over/under-run the payload
const body = try buildCfileBody(gpa, test_hash_a, rand.int(u16), csize, raw[0..plen]);
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
_ = client.consumeReply(gpa, ms.store(), body) catch |e| switch (e) {
error.OutOfMemory, error.FatalProtocolError, error.NotImplemented => continue,
};
}
}
}
// ---------------------------------------------------------------------------
// §7.5 — Private-content receive-side gates through consumeReply.
// ---------------------------------------------------------------------------
test "sync §7.5 L2383-2385: a private file without the grant is discarded without error" {
const gpa = std.testing.allocator;
// `private` then a full-content file card, with no send-private grant.
const body = "private\nfile " ++ test_hash_a ++ " 5\nHELLO";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(client.state != .aborted); // discarded without error
try std.testing.expect(ms.store().lookup(test_hash_a) == null); // store untouched
try std.testing.expect(obs.private_content_discarded);
try std.testing.expect(!obs.versioned_content_delivered); // no §3.7(a) progress
try std.testing.expect(!obs.private_content_delivered);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); // no phantom created
// consumeReply asserts cursor == body.len, so the offset resumed past the
// 5 payload bytes: HELLO was never parsed as a card.
}
test "sync §7.5 L2383-2385: a private cfile without the grant is discarded without decoding" {
const gpa = std.testing.allocator;
// A CSIZE-framed payload that is NOT a valid §4.1 container: if the store
// path ran, zlibContainerDecode would abort — so a non-aborting round
// proves the discard gate fires before any decode.
const garbage = "not a fossil zlib container at all";
const cfile = try buildCfileBody(gpa, test_hash_a, 99, garbage.len, garbage);
defer gpa.free(cfile);
const body = try std.fmt.allocPrint(gpa, "private\n{s}", .{cfile});
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(client.state != .aborted); // no decode required to advance
try std.testing.expect(ms.store().lookup(test_hash_a) == null); // store untouched
try std.testing.expect(obs.private_content_discarded);
try std.testing.expect(!obs.versioned_content_delivered);
}
test "sync §7.5 L2381-2385: a private file with the grant is stored and dephantomized" {
const gpa = std.testing.allocator;
// A public igot seeds the phantom; `private` then the file card, with the
// grant present, stores the content and removes the phantom.
const body = "igot " ++ test_hash_a ++ "\nprivate\nfile " ++ test_hash_a ++ " 5\nHELLO";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings("HELLO", ms.store().content(rid).?); // store.put occurred
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!obs.private_content_discarded);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); // phantom for HASH removed
}
test "sync §7.5 L2381-2385: the store gate is general — a private cfile with the grant is decoded and stored" {
const gpa = std.testing.allocator;
const plaintext = "private cfile stored";
const enc = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(enc);
const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc);
defer gpa.free(cfile);
const body = try std.fmt.allocPrint(gpa, "private\n{s}", .{cfile});
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); // decoded + stored
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(obs.versioned_content_delivered);
}
test "sync §7.5 receive: a private marker survives uvfile and binds a later file" {
const gpa = std.testing.allocator;
const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.NotImplemented,
client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n"),
);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(client.private_pending);
const obs = try client.consumeReply(gpa, ms.store(), "file " ++ test_hash_a ++ " 5\nHELLO");
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings("HELLO", ms.store().content(rid).?);
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!obs.private_content_discarded);
try std.testing.expect(!client.private_pending);
}
test "sync §7.5 receive: a private marker survives uvfile and binds a later cfile" {
const gpa = std.testing.allocator;
const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
const plaintext = "private cfile after uvfile";
const encoded = try zlibContainerEncode(gpa, plaintext, .default);
defer gpa.free(encoded);
const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, encoded.len, encoded);
defer gpa.free(cfile);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(gpa);
try std.testing.expectError(
Client.ConsumeError.NotImplemented,
client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n"),
);
try std.testing.expectEqual(ClientState.exchange, client.state);
try std.testing.expect(client.private_pending);
const obs = try client.consumeReply(gpa, ms.store(), cfile);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?);
try std.testing.expect(obs.private_content_delivered);
try std.testing.expect(!obs.private_content_discarded);
try std.testing.expect(!client.private_pending);
}
test "sync §7.5 receive: an end-of-reply private marker is nonfatal and remains pending" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), "private\n");
try std.testing.expectEqual(ClientState.terminated, client.state);
try std.testing.expect(client.private_pending);
}
test "sync §7.5 L2476-2478: a private igot is not an error and processing continues" {
const gpa = std.testing.allocator;
// A private igot followed by a second card in the same body: no abort, and
// the trailing cookie card is dispatched (its store effect is observable).
const body = "igot " ++ test_hash_a ++ " 1\ncookie tok\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(client.state != .aborted);
try std.testing.expect(obs.cookie_stored); // the following card was processed
}
test "sync §7.5 L2476-2478: an unauthorized private igot for an unknown hash creates no phantom" {
const gpa = std.testing.allocator;
// §7.5 L2476-2478 scopes the no-phantom disposition to a private igot from
// a peer that LACKS private-content authorization: at the client receiver
// that is private_granted == false (no accepted send-private pragma). Such
// an igot is not an error and does not intern or phantom HASH (lookup, not
// intern) — the content will never be sent to this receiver.
const body = "igot " ++ test_hash_a ++ " 1\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
try std.testing.expect(!obs.phantom_created);
try std.testing.expect(ms.store().lookup(test_hash_a) == null); // lookup, not intern
}
test "sync §7.5 L2481-2483: an authorized private igot for an unknown hash creates a phantom to pull it" {
const gpa = std.testing.allocator;
// §7.5 L2481-2483 — when the receiver holds private-content authorization
// (private_granted, the send-private pragma accepted and gated on the
// client holding Private) it MAY pull private content, so a private igot is
// handled like a public one (§3.3 r24): it interns HASH and creates a
// phantom so §3.3(c) requests the content, rather than suppressing it.
const body = "igot " ++ test_hash_a ++ " 1\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(ms.store().lookup(test_hash_a) != null); // interned, unlike the unauthorized case
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expect(client.phantoms.getPtr(rid) != null);
try std.testing.expect(obs.phantom_created);
try std.testing.expectEqual(@as(usize, 1), client.phantoms.count());
}
test "sync §7.5 L2478-2480: a private igot marks an existing phantom private" {
const gpa = std.testing.allocator;
// A public igot creates the phantom; the private igot for the same hash
// marks it private without adding a second entry.
const body = "igot " ++ test_hash_a ++ "\nigot " ++ test_hash_a ++ " 1\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
const rid = ms.store().lookup(test_hash_a).?;
try std.testing.expect(client.phantoms.getPtr(rid).?.private);
try std.testing.expect(obs.phantom_marked_private);
try std.testing.expectEqual(@as(usize, 1), client.phantoms.count());
}
test "sync §7.5 L2479-2480 / §3.3 L845-846: a marked-private phantom is gimme-ineligible" {
const gpa = std.testing.allocator;
const body = "igot " ++ test_hash_a ++ "\nigot " ++ test_hash_a ++ " 1\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), body);
const rid = ms.store().lookup(test_hash_a).?;
const marked = client.phantoms.getPtr(rid).?.private;
try std.testing.expect(marked);
// §3.3(c): with private-content sync not enabled, the marked-private phantom
// is not gimme-eligible — the receiver stops re-requesting it (the single-
// round half of deferred DST invariant (a)).
try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = marked, .private_sync_enabled = false }));
}
test "sync §7.5 L2480-2481: a private igot records peer-has and processing continues" {
const gpa = std.testing.allocator;
const body = "igot " ++ test_hash_a ++ " 1\ncookie tok\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.private_igot_peer_has);
try std.testing.expect(obs.cookie_stored); // a trailing card is still processed
}
test "sync §7.5 L2482-2483: send-private is emitted only when the client holds Private" {
// §7.5 L2482-2483 — pulling private content MUST be preceded by a send-
// private the client is entitled to send; §10.5 L3395-3397 gates that
// entitlement on the client holding the Private (x) capability.
const Case = struct { wants: bool, has_x: bool, want: bool };
const cases = [_]Case{
.{ .wants = true, .has_x = true, .want = true },
.{ .wants = true, .has_x = false, .want = false },
.{ .wants = false, .has_x = true, .want = false },
.{ .wants = false, .has_x = false, .want = false },
};
for (cases) |c| try std.testing.expectEqual(c.want, clientSendsSendPrivate(c.wants, c.has_x));
// The caller derives has_private_x from the established capability set (B1).
const held = capabilitySetFromLetters("x");
try std.testing.expect(clientSendsSendPrivate(true, held.contains(.private)));
const unheld = capabilitySetFromLetters("oi");
try std.testing.expect(!clientSendsSendPrivate(true, unheld.contains(.private)));
}