//! 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 rfc_vectors = @import("rfc_vectors.zig");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const delta = @import("delta.zig");
const sync = @import("sync.zig");
const codec = @import("container.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 = codec.Level;
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 Server = sync.Server;
const ServerUvRequest = sync.ServerUvRequest;
const ShunSet = sync.ShunSet;
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 SubmanifestConfig = sync.SubmanifestConfig;
const parseSubmanifestConfigPayload = sync.parseSubmanifestConfigPayload;
const writeSubmanifestConfigPayload = sync.writeSubmanifestConfigPayload;
const submanifest_config_mtime = sync.submanifest_config_mtime;
const submanifest_config_payload_max = sync.submanifest_config_payload_max;
const submanifest_setting_auto_retune = sync.submanifest_setting_auto_retune;
const submanifest_setting_enable = sync.submanifest_setting_enable;
const submanifest_setting_k = sync.submanifest_setting_k;
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 = codec.decode;
const zlibContainerEncode = codec.encode;
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: zit bounds card/comment lines at 65535 bytes including LF (local limit; §5.2 L962 imposes none)" {
// A ZIT limit, not a protocol one: §5.2 L962 says "This protocol imposes
// no separate maximum line length", and §11.3 L2199 repeats it. An
// over-length line (no LF within our window) is MissingTerminator because
// `readLine` stops scanning there -- a conforming peer may legally send a
// longer line, and we refuse it as a resource limit of our own.
var ok_buf: [max_line_len]u8 = undefined;
@memset(ok_buf[0 .. max_line_len - 1], 'x');
ok_buf[max_line_len - 1] = '\n';
const ok_line = try readLine(&ok_buf, 0);
try std.testing.expectEqual(@as(usize, max_line_len - 1), ok_line.bytes.len);
var over_buf: [max_line_len]u8 = undefined;
@memset(&over_buf, 'x');
try std.testing.expectError(LineError.MissingTerminator, readLine(&over_buf, 0));
}
test "sync §5.2: the line is parsed through LF, excluding the byte-counted payload" {
// §5.2 L961: "Card and comment lines are parsed through LF before payload
// framing." Our window is measured the same way -- through the terminating
// LF, excluding 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: an over-length line aborts before card dispatch (zit limit; replyBodyStep .truncated, never .card_ready)" {
// Consequence of our own window, not of a draft clause: a line we refuse
// is never interpreted as a card, nor is any following payload consumed.
// 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 §11.3 L2200-2201: declared byte counts, not the zit line window, govern payloads" {
// §11.3 L2200-2201: "entity and payload limits remain governed by their
// declared counts and storage policy." So our line window must not cap the
// SIZE/CSIZE payload of a file/cfile/uvfile/config card. 70000 is the
// smallest round value above it.
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 §9.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 §9.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 §9.3/§8.4: server answers reqconfig /all with no error and no config cards" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.self_servercode = "s",
.project_code = "p",
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §8.4 — the client's deferred configuration request in the post-clone
// cleanup round: an undated client-version (no server-version solicited),
// then reqconfig /all. zit models no config group, so its group mask is
// zero: the reply carries NO config cards and — the point of the fix —
// NO `error bad command`, so a clone completes past the config phase.
const reply = try server.respond(gpa, ms.store(), "pragma client-version 20000\nreqconfig /all\n", &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error") == null);
try std.testing.expect(std.mem.indexOf(u8, reply, "config") == null);
}
test "sync §9.3/§9.3 r20: a reqconfig NAME without a leading slash is error bad command" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } } };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §9.3 r20 L1882 — NAME MUST begin with '/'; a bare group name is a
// §5.2 malformed card, reset-and-error like any other bad command.
const reply = try server.respond(gpa, ms.store(), "reqconfig all\n", &out);
try std.testing.expect(std.mem.startsWith(u8, reply, "error bad\\scommand:"));
}
test "sync §9.1: deriveProjectCode is a 40-hex artifact hash, distinct and deterministic" {
const a = sync.deriveProjectCode(&.{"/tmp/repo-a/.zit"});
const b = sync.deriveProjectCode(&.{"/tmp/repo-b/.zit"});
// §12 ABNF `projectcode = artifact-hash` — 40 lowercase-hex bytes.
try std.testing.expectEqual(@as(usize, 40), a.len);
for (a) |c| try std.testing.expect((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'));
// Distinct repositories (distinct entropy) get distinct codes; never the
// all-zero placeholder.
try std.testing.expect(!std.mem.eql(u8, &a, &b));
try std.testing.expect(!std.mem.eql(u8, &a, "0" ** 40));
// Deterministic: the same entropy replays the same code (no ambient RNG).
const a2 = sync.deriveProjectCode(&.{"/tmp/repo-a/.zit"});
try std.testing.expectEqualSlices(u8, &a, &a2);
}
test "sync §8.3/§9.1: a clone reply's push card teaches the client the peer project code" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
// §8.2 — the clone reply closes with `clone_seqno NEXT` then `push
// SERVERCODE PROJECTCODE`; the client records the peer's project code from
// it (§3.2 L405-408) for the CLI to persist as the shared identity (§9.1).
const pcode = "aa87ea41b0bb05ba528f7a3bdba700d7e23d6318";
_ = try client.consumeReply(gpa, ms.store(), "clone_seqno 0\npush 1111111111111111 " ++ pcode ++ "\n");
try std.testing.expectEqualStrings(pcode, client.peerProjectCode());
}
test "sync §6/§8: a clone with a pending login signs no round-0 card and a login card on every round after" {
// A private repository is unreadable anonymously, and a clone cannot sign
// a login before the peer's project code arrives in the round-0 reply.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .clone_round0, .pending_login = .{ .user = "alice", .password = "pw" } };
defer client.deinit(gpa);
var out0: std.ArrayListUnmanaged(u8) = .empty;
defer out0.deinit(gpa);
const round0 = try client.buildRequest(gpa, ms.store(), &out0);
try std.testing.expect(std.mem.indexOf(u8, round0, "login ") == null);
const pcode = "aa87ea41b0bb05ba528f7a3bdba700d7e23d6318";
_ = try client.consumeReply(gpa, ms.store(), "clone_seqno 0\npush 1111111111111111 " ++ pcode ++ "\n");
try std.testing.expect(client.pending_login == null);
const cred = client.credential orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("alice", cred.user);
try std.testing.expectEqualStrings(&deriveSharedSecret(pcode, "alice", "pw"), &cred.secret_hex);
var out1: std.ArrayListUnmanaged(u8) = .empty;
defer out1.deinit(gpa);
const round1 = try client.buildRequest(gpa, ms.store(), &out1);
// The card follows the pragma lines; its nonce covers everything after it.
try std.testing.expect(std.mem.indexOf(u8, round1, "login alice ") != null);
}
test "sync §6/§8: a refused anonymous round 0 that taught the project code is retried with the login, as fossil does" {
// fossil's server answers a read-gated clone's first, login-less round
// with `push SERVERCODE PROJECTCODE` and `error not authorized to clone`;
// its client discards the error on round 0 and comes back with a login.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const pcode = "aa87ea41b0bb05ba528f7a3bdba700d7e23d6318";
const refusal = "push 1111111111111111 " ++ pcode ++ "\nerror not\\sauthorized\\sto\\sclone\n";
var anon = Client{ .operation = .clone, .state = .clone_round0 };
defer anon.deinit(gpa);
try std.testing.expectError(error.FatalProtocolError, anon.consumeReply(gpa, ms.store(), refusal));
var named = Client{ .operation = .clone, .state = .clone_round0, .pending_login = .{ .user = "alice", .password = "pw" } };
defer named.deinit(gpa);
_ = try named.consumeReply(gpa, ms.store(), refusal);
try std.testing.expect(named.credential != null);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const round1 = try named.buildRequest(gpa, ms.store(), &out);
try std.testing.expect(std.mem.indexOf(u8, round1, "login alice ") != null);
try std.testing.expect(std.mem.indexOf(u8, round1, "clone 3 ") != null);
}
test "sync §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.3 r32: pushed config card with negative SIZE is fatal (rejection)" {
try std.testing.expectError(ConfigError.NegativeSize, parseConfigCard(tokenizeCard("config /config -1")));
}
test "sync §9.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 §9.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 §9.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")));
}
// --- §9.3 scalar /config propagation of the three submanifest settings -----
//
// draft-fossil-submanifests-00 §6.3 requires PROJECT scope for the partition
// settings ("configuration propagating to clones"), and §8.1 says the same of
// `enable-submanifests`; §6.4's `submanifest-auto-retune` is the
// third. [FossilSync] §9.3's scalar channel is the transport, so these tests
// pin the wire form, the server's two dispositions, and the client's latch.
test "sync §9.3 submanifests §6.3/§8.1: the three settings round-trip through one scalar payload" {
var buf: [submanifest_config_payload_max]u8 = undefined;
const payload = try writeSubmanifestConfigPayload(true, 3, true, &buf);
// The exact wire bytes: three LF-separated §9.3 r25 rows, each MTIME SP
// QUOTED-NAME SP value SP QUOTED-VALUE, booleans as on/off and k decimal.
// MTIME is the 0 sentinel — zit's config frame tracks no per-field write
// time, so 0 says "untracked" rather than inventing a plausible clock.
try std.testing.expectEqualStrings(
"0 'enable-submanifests' value 'on'\n" ++
"0 'submanifest-k' value '3'\n" ++
"0 'submanifest-auto-retune' value 'on'",
payload,
);
try std.testing.expectEqual(@as(u64, 0), submanifest_config_mtime);
const got = parseSubmanifestConfigPayload(payload);
try std.testing.expectEqual(@as(?bool, true), got.enable);
try std.testing.expectEqual(@as(?u8, 3), got.k);
try std.testing.expectEqual(@as(?bool, true), got.auto_retune);
try std.testing.expect(got.any());
// The off/unpinned triple round-trips too — a defaulted origin is a real
// wire case, not an absence.
var off_buf: [submanifest_config_payload_max]u8 = undefined;
const off_payload = try writeSubmanifestConfigPayload(false, 0, false, &off_buf);
const off = parseSubmanifestConfigPayload(off_payload);
try std.testing.expectEqual(@as(?bool, false), off.enable);
try std.testing.expectEqual(@as(?u8, 0), off.k);
try std.testing.expectEqual(@as(?bool, false), off.auto_retune);
}
test "sync §9.3: an unknown scalar name or an unparseable value is skipped, never fatal" {
// §3.5 tolerate-and-ignore at row granularity: a peer that carries settings
// this implementation does not model, or a value shape it cannot read, must
// leave the rows it DOES understand intact and raise nothing.
const mixed =
"0 'autosync' value 'on'\n" ++ // a real Fossil setting zit models not
"0 'enable-submanifests' value 'on'\n" ++
"0 'submanifest-k' value 'banana'\n" ++ // unparseable k
"not-a-row\n" ++ // not even §9.3 r25 shaped
"0 'submanifest-auto-retune' value 'off'";
const got = parseSubmanifestConfigPayload(mixed);
try std.testing.expectEqual(@as(?bool, true), got.enable);
try std.testing.expectEqual(@as(?u8, null), got.k); // skipped, not defaulted
try std.testing.expectEqual(@as(?bool, false), got.auto_retune);
// An empty payload names nothing: the all-null triple, which the adoption
// wrapper reads as "peer sent none; keep local".
const none = parseSubmanifestConfigPayload("");
try std.testing.expect(!none.any());
}
test "sync §9.3/§9.3 r21: a server with Read answers reqconfig /config with the three settings" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.self_servercode = "s",
.project_code = "p",
.submanifests_enabled = true,
.submanifest_k = 5,
.submanifest_auto_retune = true,
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ms.store(), "pragma client-version 20000\nreqconfig /config\n", &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error") == null);
// The card header frames its payload by byte count (§9.3 r23/r29), and the
// payload carries this repository's three current values.
var want_buf: [submanifest_config_payload_max]u8 = undefined;
const want_payload = try writeSubmanifestConfigPayload(true, 5, true, &want_buf);
var head_buf: [64]u8 = undefined;
const head = try sync.writeConfigCard(config_scalar_marker, @intCast(want_payload.len), &head_buf);
const at = std.mem.indexOf(u8, reply, head) orelse return error.TestUnexpectedResult;
const payload_start = at + head.len + 1;
const framed = try consumeConfigPayload(reply, payload_start, @intCast(want_payload.len));
try std.testing.expectEqualStrings(want_payload, framed.bytes);
}
test "sync §9.3: a peer that never asks gets no config card (group mask zero, no error)" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.submanifests_enabled = true,
.submanifest_k = 5,
.submanifest_auto_retune = true,
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// An older peer's request: no reqconfig at all. §9.3's "no cards, no error"
// extends to it trivially — the reply is what it was before this feature.
const silent = try server.respond(gpa, ms.store(), "pragma client-version 20000\npull s p\n", &out);
try std.testing.expect(std.mem.indexOf(u8, silent, "config") == null);
try std.testing.expect(std.mem.indexOf(u8, silent, "error") == null);
// And a peer that asks for a TABLE group still gets the zero-mask no-op.
var out2: std.ArrayListUnmanaged(u8) = .empty;
defer out2.deinit(gpa);
const table = try server.respond(gpa, ms.store(), "pragma client-version 20000\nreqconfig /shun\n", &out2);
try std.testing.expect(std.mem.indexOf(u8, table, "config /") == null);
try std.testing.expect(std.mem.indexOf(u8, table, "error") == null);
}
test "sync §9.3 r31: a pushed config card without Admin is rejected verbatim and applies nothing" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// No credential: zit's anonymous server can never grant Admin, so every
// pushed config card is rejected — the safe direction §9.3 r31 requires.
const server = sync.Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } } };
var payload_buf: [submanifest_config_payload_max]u8 = undefined;
const payload = try writeSubmanifestConfigPayload(true, 7, true, &payload_buf);
var request_buf: [512]u8 = undefined;
const request = try std.fmt.bufPrint(
&request_buf,
"pragma client-version 20000\nconfig /config {d}\n{s}\n",
.{ payload.len, payload },
);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respondWithMedia(gpa, ms.store(), request, null, &out);
// §11.1 r2 — an error card's free text is fossilize-encoded, so the RFC's
// exact TEXT travels with its spaces escaped. Both halves are pinned: the
// literal wire bytes, and the unescaped string §9.3 r31 fixes.
try std.testing.expectEqualStrings("error not\\sauthorized\\sto\\spush\\sconfiguration\n", reply.body);
try std.testing.expectEqualStrings("not authorized to push configuration", config_push_unauthorized_text);
// Nothing to apply: the rejection is reset-and-emit, so no setting escapes.
try std.testing.expect(!reply.config_apply.any());
}
test "sync §9.3 r33: a pushed config card from a verified login is applied" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const secret: [40]u8 = "0123456789abcdef0123456789abcdef01234567".*;
const server = sync.Server{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.credential = .{ .user = "drh", .secret_hex = secret },
};
var payload_buf: [submanifest_config_payload_max]u8 = undefined;
const payload = try writeSubmanifestConfigPayload(true, 7, false, &payload_buf);
// §6.2 — the nonce covers exactly the bytes after the login LF, so the
// signed region is built first and the login card prefixed onto it.
var tail_buf: [512]u8 = undefined;
const tail = try std.fmt.bufPrint(&tail_buf, "config /config {d}\n{s}\n", .{ payload.len, payload });
const nonce = computeNonce(tail);
const signature = computeSignature(&nonce, &secret);
var request_buf: [768]u8 = undefined;
const request = try std.fmt.bufPrint(
&request_buf,
"login drh {s} {s}\n{s}",
.{ &nonce, &signature, tail },
);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expect(std.mem.indexOf(u8, reply.body, "error") == null);
try std.testing.expectEqual(@as(?bool, true), reply.config_apply.enable);
try std.testing.expectEqual(@as(?u8, 7), reply.config_apply.k);
try std.testing.expectEqual(@as(?bool, false), reply.config_apply.auto_retune);
}
/// A request body carrying `cards` and then one full-content file card for
/// `content`, named by its SHA3-256 hash into `name`.
fn writeRequest(buf: []u8, cards: []const u8, content: []const u8, name: *[64]u8) ![]u8 {
const hash = artifact.artifactName(.sha3_256, content, name);
return std.fmt.bufPrint(buf, "pragma client-version 20000\n{s}file {s} {d}\n{s}", .{ cards, hash, content.len, content });
}
test "sync: file content without a push card is refused whole and stores nothing" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// No credential, so anyone may push; still a pull is not a push.
const server = sync.Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } } };
var name: [64]u8 = undefined;
var request_buf: [512]u8 = undefined;
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
for ([_][]const u8{ "pull sc pc\n", "" }) |cards| {
const request = try writeRequest(&request_buf, cards, "written without a push\n", &name);
const reply = try server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expectEqualStrings("error not\\sauthorized\\sto\\swrite\n", reply.body);
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(name[0..64]));
}
// The same content with a push card lands.
const request = try writeRequest(&request_buf, "push sc pc\n", "written without a push\n", &name);
const reply = try server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expect(std.mem.indexOf(u8, reply.body, "error") == null);
try std.testing.expect(ms.store().lookup(name[0..64]) != null);
}
test "sync: an igot from a client that is not pushing makes no phantom" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } } };
const hash = "b" ** 64;
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
_ = try server.respondWithMedia(gpa, ms.store(), "pragma client-version 20000\npull sc pc\nigot " ++ hash ++ "\n", null, &out);
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(hash));
// A pushing client's igot is an offer, so the server asks for it.
const reply = try server.respondWithMedia(gpa, ms.store(), "pragma client-version 20000\npush sc pc\nigot " ++ hash ++ "\n", null, &out);
try std.testing.expect(ms.store().lookup(hash) != null);
try std.testing.expect(std.mem.indexOf(u8, reply.body, "gimme " ++ hash) != null);
}
test "sync: a server with a credential refuses a push without a login" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const secret: [40]u8 = "0123456789abcdef0123456789abcdef01234567".*;
const server = sync.Server{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.credential = .{ .user = "drh", .secret_hex = secret },
};
var name: [64]u8 = undefined;
var tail_buf: [512]u8 = undefined;
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const anonymous = try writeRequest(&tail_buf, "push sc pc\n", "anonymous push\n", &name);
const refused = try server.respondWithMedia(gpa, ms.store(), anonymous, null, &out);
try std.testing.expectEqualStrings("error not\\sauthorized\\sto\\swrite\n", refused.body);
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(name[0..64]));
// An anonymous pull from the same server is still answered.
const pulled = try server.respondWithMedia(gpa, ms.store(), "pragma client-version 20000\npull sc pc\n", null, &out);
try std.testing.expect(std.mem.indexOf(u8, pulled.body, "error") == null);
// §6.2 — the nonce covers the bytes after the login LF.
const tail = try writeRequest(&tail_buf, "push sc pc\n", "signed push\n", &name);
const nonce = computeNonce(tail);
const signature = computeSignature(&nonce, &secret);
var request_buf: [768]u8 = undefined;
const request = try std.fmt.bufPrint(&request_buf, "login drh {s} {s}\n{s}", .{ &nonce, &signature, tail });
const accepted = try server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expect(std.mem.indexOf(u8, accepted.body, "error") == null);
try std.testing.expect(ms.store().lookup(name[0..64]) != null);
}
/// A server holding one credential and a reply timestamp, over a store that
/// holds `held` artifacts, for the §6.4/§6.6 reply-shape tests below.
const auth_secret: [40]u8 = "0123456789abcdef0123456789abcdef01234567".*;
const auth_server = sync.Server{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.credential = .{ .user = "drh", .secret_hex = auth_secret },
.reply_time = "2026-10-11T21:29:18",
};
const bad_login = "login drh 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000\n";
test "sync §6.4: a failed login card first is answered by the error and the timestamp comment alone" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var hb: [64]u8 = undefined;
_ = try ms.store().put(artifact.artifactName(.sha3_256, "held\n", &hb), "held\n");
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = bad_login ++ "pragma client-version 22800 20260311 113146\npull sc pc\n";
const reply = try auth_server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expectEqualStrings("error login\\sfailed\n# timestamp 2026-10-11T21:29:18 errors 1\n", reply.body);
}
test "sync §6.4: a failed login ends card processing but still serves the pull that preceded it" {
// Fossil 2.28 leaves the card loop at the failed login and then runs its
// end-of-request steps: the pull's igot inventory survives the reset that
// discards the staged file for the gimme. Cards after the login are not
// parsed, so the igot after it does not suppress that artifact's
// announcement.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var ab: [64]u8 = undefined;
const asked = artifact.artifactName(.sha3_256, "asked for\n", &ab);
_ = try ms.store().put(asked, "asked for\n");
var kb: [64]u8 = undefined;
const known = artifact.artifactName(.sha3_256, "announced after the login\n", &kb);
_ = try ms.store().put(known, "announced after the login\n");
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
var request_buf: [1024]u8 = undefined;
const request = try std.fmt.bufPrint(&request_buf, "pragma client-version 22800 20260311 113146\npull sc pc\ngimme {s}\n" ++ bad_login ++ "igot {s}\n# rnd 1\n", .{ asked, known });
const reply = try auth_server.respondWithMedia(gpa, ms.store(), request, null, &out);
var want_buf: [512]u8 = undefined;
const want = try std.fmt.bufPrint(&want_buf, "error login\\sfailed\nigot {s}\n# timestamp 2026-10-11T21:29:18 errors 1\n", .{known});
try std.testing.expectEqualStrings(want, reply.body);
}
test "sync §6.4: a push accepted before a failed login still interns its igot and asks for it" {
// With no credential every push is accepted, so the only login failure
// is a duplicate login card, which Fossil names in its own words. The push and pull before it are answered:
// the unknown igot becomes a phantom, requested before the inventory.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var hb: [64]u8 = undefined;
const held = artifact.artifactName(.sha3_256, "held\n", &hb);
_ = try ms.store().put(held, "held\n");
const unknown = "c" ** 64;
const server = sync.Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } }, .reply_time = "2026-10-11T21:29:18" };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = "push sc pc\npull sc pc\nigot " ++ unknown ++ "\n" ++ bad_login ++ bad_login;
const reply = try server.respondWithMedia(gpa, ms.store(), request, null, &out);
var want_buf: [512]u8 = undefined;
const want = try std.fmt.bufPrint(&want_buf, "error multiple\\slogin\\scards\ngimme {s}\nigot {s}\n# timestamp 2026-10-11T21:29:18 errors 1\n", .{ unknown, held });
try std.testing.expectEqualStrings(want, reply.body);
const rid = ms.store().lookup(unknown) orelse return error.TestUnexpectedResult;
try std.testing.expect(!ms.store().held(rid));
}
/// `login drh NONCE SIG` signing `tail`, with `head` placed before the login
/// card, outside the signed region.
fn signedRequest(buf: []u8, head: []const u8, tail: []const u8) ![]u8 {
const nonce = computeNonce(tail);
const signature = computeSignature(&nonce, &auth_secret);
return std.fmt.bufPrint(buf, "{s}login drh {s} {s}\n{s}", .{ head, &nonce, &signature, tail });
}
test "sync §6.4: cards before a verified login have nobody's rights; only cards after it have the signer's" {
// The signature covers only the bytes after the login card, so cards
// prepended to a captured signed request must not borrow its rights.
// Fossil evaluates rights as each card is reached.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
var head_buf: [256]u8 = undefined;
var request_buf: [1024]u8 = undefined;
var name: [64]u8 = undefined;
// push and file prepended to an honest signed pull: nothing stored.
{
const head = try writeRequest(&head_buf, "push sc pc\n", "prepended content\n", &name);
const request = try signedRequest(&request_buf, head, "pull sc pc\n");
const reply = try auth_server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expect(std.mem.startsWith(u8, reply.body, "error not\\sauthorized\\sto\\swrite\n"));
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(name[0..64]));
}
// A push before the login stays refused even for a file after it.
{
var tail_buf: [256]u8 = undefined;
const tail = try writeRequest(&tail_buf, "", "file after the login\n", &name);
const request = try signedRequest(&request_buf, "push sc pc\n", tail);
const reply = try auth_server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expect(std.mem.startsWith(u8, reply.body, "error not\\sauthorized\\sto\\swrite\n"));
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(name[0..64]));
}
// An igot before the login is not an offer from the signer.
{
const unknown = "f" ** 64;
const request = try signedRequest(&request_buf, "igot " ++ unknown ++ "\n", "push sc pc\n");
_ = try auth_server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(unknown));
}
// The same push and file after the login are the signer's, and land.
{
var tail_buf: [256]u8 = undefined;
const tail = try writeRequest(&tail_buf, "push sc pc\n", "signed content\n", &name);
const request = try signedRequest(&request_buf, "", tail);
const reply = try auth_server.respondWithMedia(gpa, ms.store(), request, null, &out);
try std.testing.expect(std.mem.indexOf(u8, reply.body, "error") == null);
try std.testing.expect(ms.store().lookup(name[0..64]) != null);
}
}
test "sync §6.6: a push refused after a pull is the pull-only message; before a pull it is the error, and the pull is served either way" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var hb: [64]u8 = undefined;
const held = artifact.artifactName(.sha3_256, "held\n", &hb);
_ = try ms.store().put(held, "held\n");
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
var want_buf: [512]u8 = undefined;
// pull, push: the nonfatal message, then the pull's inventory.
{
const reply = try auth_server.respondWithMedia(gpa, ms.store(), "pull sc pc\npush sc pc\n", null, &out);
const want = try std.fmt.bufPrint(&want_buf, "message pull\\sonly\\s-\\snot\\sauthorized\\sto\\spush\npragma avoid-submanifests\nigot {s}\n", .{held});
try std.testing.expectEqualStrings(want, reply.body);
}
// push, pull: the error form, which does not end card processing.
{
const reply = try auth_server.respondWithMedia(gpa, ms.store(), "push sc pc\npull sc pc\n", null, &out);
const want = try std.fmt.bufPrint(&want_buf, "error not\\sauthorized\\sto\\swrite\npragma avoid-submanifests\nigot {s}\n", .{held});
try std.testing.expectEqualStrings(want, reply.body);
}
// push alone: the error and nothing to serve.
{
const reply = try auth_server.respondWithMedia(gpa, ms.store(), "push sc pc\n", null, &out);
try std.testing.expectEqualStrings("error not\\sauthorized\\sto\\swrite\npragma avoid-submanifests\n", reply.body);
}
}
test "sync §6.6: a refused push's announced igot makes no phantom and the reply asks for none" {
// Push is not enabled for the request, so the server's phantoms are not
// requested and an announced hash is not interned.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const phantom = "d" ** 64;
_ = try ms.store().intern(phantom);
const unknown = "e" ** 64;
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try auth_server.respondWithMedia(gpa, ms.store(), "pull sc pc\npush sc pc\nigot " ++ unknown ++ "\n", null, &out);
try std.testing.expectEqualStrings("message pull\\sonly\\s-\\snot\\sauthorized\\sto\\spush\npragma avoid-submanifests\n", reply.body);
try std.testing.expectEqual(@as(?repostate.Rid, null), ms.store().lookup(unknown));
}
test "sync §9.3/§8.4: a clone's cleanup round requests /config; the clone-card round does not" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// §8.2 L1541-1547 — the post-clone_seqno-0 cleanup round carries no clone
// card; §9.3 "Clone defers requests" makes it the one place the client asks.
var cleanup = Client{ .operation = .clone, .state = .exchange, .clone_seqno = 0 };
defer cleanup.deinit(gpa);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const body = try cleanup.buildRequest(gpa, ms.store(), &out);
try expectHasLine(body, "reqconfig /config");
// Round 0 and every paging round carry the clone card and no request.
var paging = Client{ .operation = .clone, .state = .clone_round0, .clone_seqno = 1 };
defer paging.deinit(gpa);
var out2: std.ArrayListUnmanaged(u8) = .empty;
defer out2.deinit(gpa);
const paging_body = try paging.buildRequest(gpa, ms.store(), &out2);
try std.testing.expect(std.mem.indexOf(u8, paging_body, "reqconfig") == null);
// Scope: a pull/push/sync never asks — clone-time adoption only (a later
// refresh would need real MTIME conflict resolution the config frame has
// no storage for).
var pull = Client{ .operation = .pull };
defer pull.deinit(gpa);
var out3: std.ArrayListUnmanaged(u8) = .empty;
defer out3.deinit(gpa);
const pull_body = try pull.buildRequest(gpa, ms.store(), &out3);
try std.testing.expect(std.mem.indexOf(u8, pull_body, "reqconfig") == null);
}
test "sync §9.3: a client latches the three settings off a scalar config card" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .exchange, .clone_seqno = 0 };
defer client.deinit(gpa);
try std.testing.expect(!client.peerSubmanifestConfig().any());
var payload_buf: [submanifest_config_payload_max]u8 = undefined;
const payload = try writeSubmanifestConfigPayload(true, 4, true, &payload_buf);
var reply_buf: [512]u8 = undefined;
const reply = try std.fmt.bufPrint(&reply_buf, "config /config {d}\n{s}\n", .{ payload.len, payload });
_ = try client.consumeReply(gpa, ms.store(), reply);
const got = client.peerSubmanifestConfig();
try std.testing.expectEqual(@as(?bool, true), got.enable);
try std.testing.expectEqual(@as(?u8, 4), got.k);
try std.testing.expectEqual(@as(?bool, true), got.auto_retune);
// A TABLE group's payload is framed and skipped, exactly as before: the
// latch is untouched by a card that carries no scalar settings.
var table_client = Client{ .operation = .clone, .state = .exchange, .clone_seqno = 0 };
defer table_client.deinit(gpa);
_ = try table_client.consumeReply(gpa, ms.store(), "config /shun 3\nabc\n");
try std.testing.expect(!table_client.peerSubmanifestConfig().any());
}
test "sync §9.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 §9.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 §9.4 r37: recorded ci-lock-fault blocks only a fork-creating commit without an override" {
// §9.4 L1965-1970 records the fault and continues; the fork/override gate is zit-local: block 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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 §9.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 L803-806 — 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 L803-806 "an unknown or locally malformed reply card" — here a
// content-bearing operator carrying surplus tokens (§5 L1013-1016).
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 L797-807 — liberal ∪ strict partitions the posture; the four
// limited to the cases enumerated at L797-803; the four sampled below each
// 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" {
// §5.2 L944-946 — 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 L808-811 — 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 L1013-1016), a simple card is
// .bad_command (§5.2 L989-995 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" {
// §5.2 L957-959 — 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 L797-801 (§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 L797-802 (§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 L806-808 — 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.2 L376-377: client-version precedes content-requesting cards; checkRequestOrder rejects the inversion" {
// §3.2 L376-377: "Every request MUST contain pragma client-version. It
// MUST precede content-requesting cards so the server can gate SHA3 at
// handler time." checkRequestOrder enforces exactly that and nothing more.
//
// This previously argued that a since-removed §3.10 "restates §3.2 and
// defers to it, so introduces no new order rule". The section is gone, so
// the argument had no subject; the rule it deferred TO is the real one.
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.2.1 r2: the canonical Table-4 write order projects to a well-ordered request" {
// §3.2.1 L428-469 — 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.2.1/§5.3 r3: every request body ends with a random-bytes comment line, not the timestamp subform" {
// §3.2.1 L466-467 (Table 4) + §5.3 L1057-1058 — the request's closing
// comment carries implementation random bytes; the timestamp/errors
// subform (§5.3 L1041-1043) 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.2.1/§8.3 r7: the login-card row defers to §6 presence and §8.3 round-0 suppression" {
// §3.2.1 L431-433 — §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.2.1's
// Round Message Layout login row carries the presence 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.2.1 r4: the reply-card membership set resolves to known operators" {
// §3.2 L399-403 (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.2.1 r4: the reply body is an unordered applicable combination, not a positional sequence" {
// §3.2.1 Round Message Layout — a reply body is any applicable
// combination, so 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 §5.3 r5: every reply body ends with a timestamp/error-count comment line" {
// §3.2.1 L492-493 + §5.3 L1041-1043 — 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 L1045-1046).
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.2 r6: a reply push/pull advertisement is recorded but does not by itself select the operation" {
// §3.2 L408 — 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 L2470-2471 — 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 L2453 — 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 L412-413 — a push/sync client that holds a gimme'd hash queues it
// in the per-session outbound send buffer (§3.1 L312-314).
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 L532-535: 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 L511-514 — 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 L533-535/§3.2 L412-413: 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 L532-535: 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) L581-583: 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 L312-314).
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 L312-314: 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 L2491-2492: 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).?);
}
/// §5.1 L935 + §5.2 L944-945 — cards are LF-terminated canonical lines, 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 L2448-2453: round-1 buildRequest announces every holding with no gimme" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
// §A.1 L2451-2453 — 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 (§A.1 L2448). The
// VERSION is what `announcedVersion` raises it to (submanifests §7.2 — zit
// reads trees, so it announces the threshold); DATE and TIME are the
// client's own, unchanged.
try std.testing.expect(std.mem.startsWith(u8, body, "pragma client-version " ++
std.fmt.comptimePrint("{d}", .{sync.submanifest_min_peer_version}) ++ " 20260311 113146\n"));
// §A.1 L2449-2450 — 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 L2451-2453 — 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.2: a pull/push request still announces held artifacts with igot" {
// The default path is unchanged: a receive-only pull and a send-only push
// each announce every held artifact with igot (§3.2 L397-398), live under §8.5
// L1480-1481 — the clone suppression must not touch a non-clone operation.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(a1_manifest_hash, "manifest");
_ = try ms.store().put(a1_send_hash, "alpha");
inline for (.{ Operation.pull, Operation.push }) |op| {
var client = Client{
.operation = op,
.self_servercode = a1_servercode,
.project_code = a1_projectcode,
};
defer client.deinit(gpa);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const body = try client.buildRequest(gpa, ms.store(), &out);
try expectHasLine(body, "igot " ++ a1_manifest_hash);
try expectHasLine(body, "igot " ++ a1_send_hash);
}
}
test "sync §8.2: a clone request carries no igot cards" {
// §8.2 L1587-1594 — a clone session enables no push direction, so a client
// creates no server phantom by announcing and MUST NOT be required to: even a
// mid-clone client already holding the artifacts paged in from earlier cfile
// batches emits zero igot cards. A continuation round carries only its
// `clone 3 SEQNO` operation card (plus pragma/gimme/randomness); a cleanup
// round (§8.2 L1543-1547, SEQNO 0) carries no operation card and, despite the
// L1422 "remaining-work" permission, still no igot.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// Artifacts the client received in earlier clone rounds; a pull/push/sync
// would announce each with igot (see the preceding test).
_ = try ms.store().put(a1_manifest_hash, "manifest");
_ = try ms.store().put(a1_send_hash, "alpha");
_ = try ms.store().put(a1_held_hash, "ancestor");
// continuation round (SEQNO 5) and cleanup round (SEQNO 0) — neither igots.
inline for (.{ 5, 0 }) |seqno| {
var client = Client{ .operation = .clone, .state = .exchange, .clone_seqno = seqno };
defer client.deinit(gpa);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const body = try client.buildRequest(gpa, ms.store(), &out);
// No igot card in any position — not at body start, not after any LF.
try std.testing.expect(!std.mem.startsWith(u8, body, "igot "));
try std.testing.expect(std.mem.indexOf(u8, body, "\nigot ") == null);
// None of the held hashes appears at all (no igot smuggled another way).
try std.testing.expect(std.mem.indexOf(u8, body, a1_manifest_hash) == null);
try std.testing.expect(std.mem.indexOf(u8, body, a1_send_hash) == null);
try std.testing.expect(std.mem.indexOf(u8, body, a1_held_hash) == null);
}
// The continuation round still threads its cursor card (§8.2 L1583).
var cont = Client{ .operation = .clone, .state = .exchange, .clone_seqno = 5 };
defer cont.deinit(gpa);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
try expectHasLine(try cont.buildRequest(gpa, ms.store(), &out), "clone 3 5");
}
test "sync §3.3 L537: 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 L537 — 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 L1073-1081 / §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 L1079-1080 — 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 L1057-1058: 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 r9: the client keeps the server's messages for display, bounded, and notes a divert pragma" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .push, .state = .exchange };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), "message kept\\son\\sattempt/jan/abc\npragma zit-diverted abc attempt/jan/abc\n" ++ msg_pull_only);
// The advisory message is kept decoded; `pull only` is the protocol's own
// signal and is never shown.
try std.testing.expectEqual(@as(usize, 1), client.server_messages.items.len);
try std.testing.expectEqualStrings("kept on attempt/jan/abc", client.server_messages.items[0]);
try std.testing.expect(client.diverted);
var chatty = Client{ .operation = .sync, .state = .exchange };
defer chatty.deinit(gpa);
var body: std.ArrayListUnmanaged(u8) = .empty;
defer body.deinit(gpa);
for (0..sync.max_server_messages + 5) |_| try body.appendSlice(gpa, msg_non_match);
_ = try chatty.consumeReply(gpa, ms.store(), body.items);
try std.testing.expectEqual(sync.max_server_messages, chatty.server_messages.items.len);
try std.testing.expect(!chatty.diverted);
}
test "sync §11.2: a one-token message card is rejected as an arity failure" {
// §10.2 L2118-2119 — "Exactly two visible tokens are required", so not "whitespace-
// separated tokens". A bare `message` with no TEXT is a defined-operator
// arity failure (§5.2 L976-978), which the client records as a local
// warning and aborts on (§5.2 L1024-1027, §3.9 L804-806) — 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" {
// §10.2 L2118 — exactly two visible tokens. `message pull only` exposes
// three visible tokens (operator + two args), a defined-operator arity
// failure (§5.2 L976-978) that aborts (§5.2 L1024-1027). 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 one trailing /xfer segment, origin path never consulted" {
const cases = [_]struct { location: []const u8, want: []const u8 }{
// One strip, as Fossil's http_exchange does: /a/xfer/xfer -> /a/xfer.
// The second /xfer is part of the repository path, not the endpoint.
.{ .location = "/a/xfer/xfer", .want = "/a/xfer" },
.{ .location = "http://h.example/b/xfer/xfer", .want = "http://h.example/b/xfer" },
// 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: parseMediaType ignores parameters and case" {
// gaia-gis.it answers a clone round with this exact header; read as an
// exact string it fell through to "compressed" and the plaintext body's
// first bytes became a 1.6 GB container length.
try std.testing.expectEqual(MediaType.x_fossil_uncompressed, parseMediaType("application/x-fossil-uncompressed; charset=utf-8").?);
try std.testing.expectEqual(MediaType.x_fossil, parseMediaType("application/x-fossil;charset=UTF-8").?);
try std.testing.expectEqual(MediaType.x_fossil, parseMediaType("Application/X-Fossil").?);
try std.testing.expectEqual(MediaType.x_fossil_debug, parseMediaType(" application/x-fossil-debug ; q=1").?);
// A longer type is not a match: x-fossil is not a prefix rule.
try std.testing.expect(parseMediaType("application/x-fossil-uncompressedx") == null);
try std.testing.expect(parseMediaType("application/x-fossil-") == null);
}
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/§11.1-r2: computeNonce('example-request-body-bytes')" {
// §6.2 L1085-1087 defines it: 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 §A.5: login card vectors" {
// Direct, non-circular pin of the sync RFC §A.5 published worked example
// (draft-fossil-sync-protocol-00.txt). Every literal below is transcribed
// by hand from the RFC — the RFC bytes are the oracle, never zit's own hash:
// L2615 USER = alice
// L2617 password = secret-passphrase
// L2619 project code = c1b2a3d4e5f60718293a4b5c6d7e8f9012345678
// L2638 NONCE = 5dfa03bd8645938b4e312a4fcdad5ebe6d4d4840
// L2641 shared-secret = d7a235115bf97d126c42140196c4fc6087312353
// L2643 SIGNATURE = e3dba1c99442823b76ae64a8afca940e0327f9f8
const secret = deriveSharedSecret(
"c1b2a3d4e5f60718293a4b5c6d7e8f9012345678",
"alice",
"secret-passphrase",
);
try std.testing.expectEqualStrings("d7a235115bf97d126c42140196c4fc6087312353", &secret);
// Feed §A.5's PUBLISHED NONCE and shared-secret hex literals straight in —
// not computeNonce/deriveSharedSecret output — so this pin of the §A.5
// SIGNATURE is anchored on the RFC's own bytes, not on any zit computation.
const signature = computeSignature(
"5dfa03bd8645938b4e312a4fcdad5ebe6d4d4840",
"d7a235115bf97d126c42140196c4fc6087312353",
);
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/§11.1-r3: distinct body tails yield distinct nonces (replay note)" {
// §11.1 L2170-2171: independently generated rounds produce distinct
// nonces. Its causal mechanism — the per-request random comment line of
// §5.3 (L1057-1058) — 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 §11.1-r1: neither password nor shared secret appears in the login body card" {
// §11 L2158-2159: 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 L2643)" {
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, §A.3 L2664)" {
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 §A.3 L2664 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 §11.2-r5: writeCookieLogin never places the shared secret in the x-f-l-c cookie" {
// §6.5 L1163 (+ §11 preamble L2158-2159): the shared secret is structurally never
// 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 §11.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 §11.1-r4: a byte-identical login verifies .ok twice (no nonce cache, no freshness check)" {
// §11.1 L2171-2174: 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 L1085-1087: 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 L1085-1087: 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 L1085-1087: 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 L1079-1080: 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 L1079-1080: 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)" {
// §6.1 L1080-1081 + §8.3 L1598-1599: 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 L1075-1077: 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: cookie value — ~ passes unescaped, SP -> +, $ / : are percent-encoded" {
// §B L2843-2844: 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: 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: 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: 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: cookie decode accepts case-insensitive %XX but rejects %20 in a separator slot" {
// §B L2842-2845: 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 L2844), 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/§9.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 §9.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 L1128-1130: 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 L1189-1191: of the enumerated letters, only o/i/g/x name a transfer
// 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: file and cfile cards name artifacts by complete lowercase hash, or are malformed" {
// Fossil 2.28 refuses a file or cfile card whose HASH or DELTASRC is not
// an artifact name as `malformed file line` / `malformed cfile line`.
const bad = [_][]const u8{ "a" ** 39, "a" ** 41, "A" ** 40, "g" ** 64, "aa" };
for (bad) |name| {
var buf: [256]u8 = undefined;
const as_hash = try std.fmt.bufPrint(&buf, "file {s} 42", .{name});
try std.testing.expectError(error.Malformed, parseFileCard(tokenizeCard(as_hash)));
var buf2: [256]u8 = undefined;
const as_src = try std.fmt.bufPrint(&buf2, "file " ++ test_hash_a ++ " {s} 42", .{name});
try std.testing.expectError(error.Malformed, parseFileCard(tokenizeCard(as_src)));
var buf3: [256]u8 = undefined;
const c_hash = try std.fmt.bufPrint(&buf3, "cfile {s} 42 10", .{name});
try std.testing.expectError(error.Malformed, parseCfileCard(tokenizeCard(c_hash)));
var buf4: [256]u8 = undefined;
const c_src = try std.fmt.bufPrint(&buf4, "cfile " ++ test_hash_a ++ " {s} 42 10", .{name});
try std.testing.expectError(error.Malformed, parseCfileCard(tokenizeCard(c_src)));
}
}
test "sync §7.1: a pushed delta naming a malformed DELTASRC is a malformed file line and mints no phantom" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } } };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ms.store(), "push sc pc\nfile " ++ "c" ** 64 ++ " zzzz 3\nabc\n", &out);
try std.testing.expectEqualStrings("error malformed\\sfile\\sline\n", reply);
try std.testing.expectEqual(@as(repostate.Rid, 0), ms.store().count());
}
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 = rfc_vectors.sync_a1_round2_file_cards;
test "sync §7.1: worked example — first file card frames the 272-byte manifest (§A.1 L2483-2490)" {
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 L2491-2492)" {
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{};
defer queue.deinit(std.testing.allocator);
const delta_bytes = "opaque delta payload, not yet decoded";
try queue.retain(std.testing.allocator, card.hash, card.deltasrc.?, .{ .raw = delta_bytes });
try std.testing.expectEqual(@as(usize, 1), queue.itemCount());
try std.testing.expectEqualStrings(card.hash, queue.items.items[0].?.hash);
try std.testing.expectEqualStrings(card.deltasrc.?, queue.items.items[0].?.deltasrc);
try std.testing.expectEqualStrings(delta_bytes, queue.items.items[0].?.payload.raw);
// 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/§4: the retention ceiling is derived from the reply body cap, not an arbitrary constant" {
// The ceiling is `reply_body_cap / min_delta_card_bytes` — the maximum
// number of delta cards a conformant reply body could ever carry. It must be
// finite (never unbounded allocation) and vastly larger than a real clone's
// dangling-delta count (fossil-scm walled the old fixed 256 at 257).
try std.testing.expectEqual(
sync.reply_body_cap / PendingDeltaQueue.min_delta_card_bytes,
PendingDeltaQueue.max_pending,
);
// Tightness: the ceiling times the minimum card size does not exceed the
// body cap, and one more card would — so the derivation is exact, not slack.
try std.testing.expect(PendingDeltaQueue.max_pending * PendingDeltaQueue.min_delta_card_bytes <= sync.reply_body_cap);
try std.testing.expect((PendingDeltaQueue.max_pending + 1) * PendingDeltaQueue.min_delta_card_bytes > sync.reply_body_cap);
try std.testing.expect(PendingDeltaQueue.max_pending > 1_000_000); // a real clone can never approach it
}
test "sync §7.4-r1: retention rejects past its ceiling with a clean QueueFull, never OOM (bound)" {
// A synthetic over-bound retain: lower the ceiling so the negative-space edge
// is reachable without materializing the millions of entries the real
// `max_pending` would require. Retaining up to `cap` succeeds; the next one
// returns QueueFull rather than allocating past the bound.
var queue = PendingDeltaQueue{ .cap = 3 };
defer queue.deinit(std.testing.allocator);
var round: usize = 0;
while (round < queue.cap) : (round += 1) {
try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = "d" });
}
try std.testing.expectEqual(queue.cap, queue.itemCount());
try std.testing.expectError(PendingDeltaQueue.RetainError.QueueFull, queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = "d" }));
try std.testing.expectEqual(queue.cap, queue.itemCount()); // rejection left the queue unchanged
}
test "sync §7.4-r1/r3: many dangling deltas past the old fixed cap are all retained then reconstructed to fixpoint" {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const store = ms.store();
// §7.4: fossil legitimately sends a delta before its basis. A real clone
// fans out far more than 256 dangling deltas against a single absent basis
// (fossil-scm walled at 257, lumosql at ~315). The queue MUST retain all of
// them, not reject the 257th.
const n = 1000;
const base = "shared basis artifact bytes for the whole delta fan-out";
const base_hash = test_hash_b;
_ = try store.intern(base_hash); // §7.4-r2 phantom for the not-yet-arrived basis
var queue = PendingDeltaQueue{};
defer queue.deinit(std.testing.allocator);
var deltas: [n][]const u8 = undefined;
var hashes: [n][40]u8 = undefined;
var made: usize = 0;
defer for (deltas[0..made]) |d| std.testing.allocator.free(d);
for (0..n) |k| {
var tgt_buf: [80]u8 = undefined;
const tgt = try std.fmt.bufPrint(&tgt_buf, "target artifact number {d} reconstructed from the shared base", .{k});
const d = try delta.create(std.testing.allocator, base, tgt);
deltas[k] = d;
made += 1;
_ = try std.fmt.bufPrint(&hashes[k], "{x:0>40}", .{k}); // distinct 40-hex target name
try queue.retain(std.testing.allocator, &hashes[k], base_hash, .{ .raw = d });
}
try std.testing.expectEqual(@as(usize, n), queue.itemCount()); // all retained, none rejected
// The basis arrives: a single completeFor must reconstruct every target.
_ = try store.put(base_hash, base);
try queue.completeFor(std.testing.allocator, store, base_hash, .unrestricted);
try std.testing.expectEqual(@as(usize, 0), queue.itemCount());
for (0..n) |k| {
var tgt_buf: [80]u8 = undefined;
const tgt = try std.fmt.bufPrint(&tgt_buf, "target artifact number {d} reconstructed from the shared base", .{k});
const rid = try store.intern(&hashes[k]);
try std.testing.expectEqualStrings(tgt, store.content(rid).?);
}
}
test "sync §7.2/§7.4: a clone client retains a received full and delta cfile verbatim; wire() re-serves the exact containers" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const s = ms.store();
const base = "the base artifact bytes with a long shared run so the forward delta is compact and its raw size differs from the reconstructed target";
const target = base ++ " with a small appended mutation";
const base_container = try zlibContainerEncode(gpa, base, .default);
defer gpa.free(base_container);
const raw = try delta.create(gpa, base, target);
defer gpa.free(raw);
const delta_container = try zlibContainerEncode(gpa, raw, .default);
defer gpa.free(delta_container);
const base_hash = test_hash_a;
const target_hash = "c" ** 40; // the delta cfile's DELTASRC is base_hash
// A clone reply in §8.2 RID order: the full base cfile precedes the delta
// cfile, so the client reconstructs the delta immediately against the held
// basis and retains the received delta container verbatim.
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n");
const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ base_hash, base.len, base_container.len });
defer gpa.free(bh);
try body.appendSlice(gpa, bh);
try body.appendSlice(gpa, base_container);
try body.append(gpa, '\n'); // §5.1 optional trailing-LF padding
const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ target_hash, base_hash, target.len, delta_container.len });
defer gpa.free(dh);
try body.appendSlice(gpa, dh);
try body.appendSlice(gpa, delta_container);
try body.append(gpa, '\n');
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, s, body.items);
// §7.2 — the base retained its received full container verbatim.
const base_rid = s.lookup(base_hash).?;
const bwf = s.wire(base_rid).?;
try std.testing.expect(bwf.delta == null);
try std.testing.expectEqualSlices(u8, base_container, bwf.container);
// §7.2 — the delta retained its received container verbatim, named its basis,
// and carries the reconstructed-target USIZE (delta §3.2), not the raw size.
const target_rid = s.lookup(target_hash).?;
const twf = s.wire(target_rid).?;
try std.testing.expect(twf.delta != null);
try std.testing.expectEqualSlices(u8, delta_container, twf.container);
try std.testing.expectEqualStrings(base_hash, twf.delta.?.src_hash);
try std.testing.expectEqual(@as(u64, target.len), twf.delta.?.target_size);
// §7.4 — content() still reconstructs the original target (delta ∘ base).
try std.testing.expectEqualStrings(target, s.content(target_rid).?);
}
test "sync §7.2/§7.4: a dangling reverse-delta cfile retains its §4.1 container through the queue; completion re-serves it verbatim" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const s = ms.store();
// A fossil reverse delta: the OLDER revision is stored as a delta of the
// NEWER one, so the delta reconstructs OLDER from NEWER.
const newer = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload";
const older = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload, cut";
const newer_container = try zlibContainerEncode(gpa, newer, .default);
defer gpa.free(newer_container);
const raw = try delta.create(gpa, newer, older); // OLDER = delta ∘ NEWER
defer gpa.free(raw);
const delta_container = try zlibContainerEncode(gpa, raw, .default);
defer gpa.free(delta_container);
const newer_hash = test_hash_a;
const older_hash = "c" ** 40;
// §8.2 clone order is ascending age: the OLDER revision's reverse-delta cfile
// arrives BEFORE its NEWER basis, so it is dangling and retained. Pre-slice-A
// the queue dropped the container and the artifact completed as full form;
// now the container survives to a verbatim §7.2 re-serve.
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n");
const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ older_hash, newer_hash, older.len, delta_container.len });
defer gpa.free(dh);
try body.appendSlice(gpa, dh);
try body.appendSlice(gpa, delta_container);
try body.append(gpa, '\n');
const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ newer_hash, newer.len, newer_container.len });
defer gpa.free(bh);
try body.appendSlice(gpa, bh);
try body.appendSlice(gpa, newer_container);
try body.append(gpa, '\n');
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, s, body.items);
// The dangling delta completed: the OLDER revision retained its received
// container VERBATIM (not a full re-compression), named its basis, carries
// the reconstructed-target USIZE, and reconstructs the original bytes.
const older_rid = s.lookup(older_hash).?;
const wf = s.wire(older_rid).?;
try std.testing.expect(wf.delta != null);
try std.testing.expectEqualSlices(u8, delta_container, wf.container);
try std.testing.expectEqualStrings(newer_hash, wf.delta.?.src_hash);
try std.testing.expectEqual(@as(u64, older.len), wf.delta.?.target_size);
try std.testing.expectEqualStrings(older, s.content(older_rid).?);
}
test "sync §7.2: a clone stores received full and delta cfiles wire-backed, holding no memoised plaintext" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const s = ms.store();
const base = "the base artifact bytes with a long shared run so the forward delta stays a compact copy payload and differs from the reconstructed target";
const target = base ++ " with a small appended mutation";
const base_container = try zlibContainerEncode(gpa, base, .default);
defer gpa.free(base_container);
const raw = try delta.create(gpa, base, target);
defer gpa.free(raw);
const delta_container = try zlibContainerEncode(gpa, raw, .default);
defer gpa.free(delta_container);
const base_hash = test_hash_a;
const target_hash = "c" ** 40; // the delta cfile's DELTASRC is base_hash
// §8.2 RID order: the full base cfile precedes the delta cfile, so the delta's
// basis is held on arrival (the clone wire-backed fast path).
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n");
const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ base_hash, base.len, base_container.len });
defer gpa.free(bh);
try body.appendSlice(gpa, bh);
try body.appendSlice(gpa, base_container);
try body.append(gpa, '\n');
const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ target_hash, base_hash, target.len, delta_container.len });
defer gpa.free(dh);
try body.appendSlice(gpa, dh);
try body.appendSlice(gpa, delta_container);
try body.append(gpa, '\n');
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, s, body.items);
// Peak stays O(wire): the clone-receive path retained only the §4.1
// containers; NO artifact's inflated plaintext is memoised in the store —
// the regression this whole slice removes (a 257 MB repo once held ~14 GB).
try std.testing.expectEqual(@as(usize, 0), ms.plaintextBytesHeld());
// §7.4 — content() still reconstructs each artifact on demand (memoising it
// only then), so the plaintext is available lazily, not held eagerly.
try std.testing.expectEqualStrings(base, s.content(s.lookup(base_hash).?).?);
try std.testing.expectEqualStrings(target, s.content(s.lookup(target_hash).?).?);
try std.testing.expect(ms.plaintextBytesHeld() > 0); // materialised by the reads above
}
test "sync §7.4: a completed dangling reverse-delta is stored wire-backed, holding no memoised plaintext" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const s = ms.store();
// A fossil reverse delta: the OLDER revision is stored as a delta of the NEWER
// one, and (§8.2 ascending age) arrives BEFORE its basis — dangling, retained
// in the pending queue, then completed when the NEWER full cfile lands.
const newer = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload";
const older = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload, cut";
const newer_container = try zlibContainerEncode(gpa, newer, .default);
defer gpa.free(newer_container);
const raw = try delta.create(gpa, newer, older); // OLDER = delta ∘ NEWER
defer gpa.free(raw);
const delta_container = try zlibContainerEncode(gpa, raw, .default);
defer gpa.free(delta_container);
const newer_hash = test_hash_a;
const older_hash = "c" ** 40;
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n");
const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ older_hash, newer_hash, older.len, delta_container.len });
defer gpa.free(dh);
try body.appendSlice(gpa, dh);
try body.appendSlice(gpa, delta_container);
try body.append(gpa, '\n');
const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ newer_hash, newer.len, newer_container.len });
defer gpa.free(bh);
try body.appendSlice(gpa, bh);
try body.appendSlice(gpa, newer_container);
try body.append(gpa, '\n');
var client = Client{ .operation = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, s, body.items);
// The dangling delta completed through the queue and was stored WIRE-BACKED —
// no reconstructed plaintext was memoised for it or its basis (peak O(wire)).
try std.testing.expectEqual(@as(usize, 0), ms.plaintextBytesHeld());
try std.testing.expectEqualStrings(older, s.content(s.lookup(older_hash).?).?);
try std.testing.expectEqualStrings(newer, s.content(s.lookup(newer_hash).?).?);
}
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{};
defer queue.deinit(std.testing.allocator);
try queue.retain(std.testing.allocator, card.hash, card.deltasrc.?, .{ .raw = 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.?, .unrestricted);
try std.testing.expectEqual(@as(usize, 0), queue.itemCount());
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{};
defer queue.deinit(std.testing.allocator);
try queue.retain(std.testing.allocator, test_hash_c, test_hash_a, .{ .raw = d_c });
try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = 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, .unrestricted);
try std.testing.expectEqual(@as(usize, 0), queue.itemCount());
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{};
defer queue.deinit(std.testing.allocator);
try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = delta_ab });
try queue.retain(std.testing.allocator, test_hash_a, other_source, .{ .raw = delta_ac });
_ = try store.put(test_hash_b, source_b);
try queue.completeFor(std.testing.allocator, store, test_hash_b, .unrestricted);
try std.testing.expectEqual(@as(usize, 1), queue.itemCount());
const survivor = for (queue.items.items) |maybe| {
if (maybe) |entry| break entry;
} else unreachable; // itemCount == 1 guarantees a live slot
try std.testing.expectEqualStrings(other_source, survivor.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{};
defer queue.deinit(allocator);
try queue.retain(allocator, test_hash_a, test_hash_b, .{ .raw = delta_bytes });
_ = try store.put(test_hash_b, source);
try queue.completeFor(allocator, store, test_hash_b, .unrestricted);
}
test "sync §7.4-r3: completeFor propagates allocation failure without leaking" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, completeForNoLeak, .{});
}
fn manyDeltaRetainCompleteNoLeak(allocator: std.mem.Allocator) !void {
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const store = ms.store();
const base = "the shared basis these many deltas all reconstruct from";
_ = try store.intern(test_hash_b);
const m = 24; // enough to force list growth (past its initial inline capacity) under injected OOM
var deltas: [m][]const u8 = undefined;
var hashes: [m][40]u8 = undefined;
var made: usize = 0;
defer for (deltas[0..made]) |d| std.testing.allocator.free(d);
var queue = PendingDeltaQueue{};
defer queue.deinit(allocator);
for (0..m) |k| {
var tgt_buf: [64]u8 = undefined;
const tgt = try std.fmt.bufPrint(&tgt_buf, "target {d} for the OOM sweep, longer than base", .{k});
const d = try delta.create(std.testing.allocator, base, tgt);
deltas[k] = d;
made += 1;
_ = try std.fmt.bufPrint(&hashes[k], "{x:0>40}", .{k});
try queue.retain(allocator, &hashes[k], test_hash_b, .{ .raw = d });
}
_ = try store.put(test_hash_b, base);
try queue.completeFor(allocator, store, test_hash_b, .unrestricted);
}
test "sync §7.4-r1/r3: growable retain + cascade completeFor propagate allocation failure without leaking" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, manyDeltaRetainCompleteNoLeak, .{});
}
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: L2652 (public igot HASH) and
// L2655 (private igot HASH 1).
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 L1434-1437 vs L1465-1466 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, .none, &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, .none, &name3_copy));
try std.testing.expect(artifact.verify(&name3_copy, good_content));
}
test "sync §7.7 L1428-1432/L1436-1437 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, .none, sha1_name));
try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, .none, sha3_name));
}
test "sync §7.7 L1434-1437: the admission PREDICATE takes no card-type and no clone input — the whole asymmetry lives at the call sites" {
// "Admission applies to the received target HASH." The decision function's
// ONLY inputs are the repository's policy, the repository's table, and the
// name; it has no `Operator` and no clone-context parameter, so `file` and
// `cfile` cannot be given different answers HERE.
//
// That is deliberately NOT the same claim as "the gate applies identically
// at both sites": L1437-1440 has a non-clone `file` discard and a clone
// `file` BYPASS, while L1440-1444 has a `cfile` discard even in clone. The
// three call-site behaviours are pinned by the L1437-1440 / L1440 / L1440-1444
// tests further down this file; what is pinned here is that the shared
// predicate carries none of it.
const params = @typeInfo(@TypeOf(admitInbound)).@"fn".params;
try std.testing.expectEqual(@as(usize, 3), params.len);
try std.testing.expectEqual(artifact.HashPolicy, params[0].type.?);
try std.testing.expectEqual(sync.ShunSet, params[1].type.?);
try std.testing.expectEqual([]const u8, params[2].type.?);
const sha1_name = "a" ** 40;
try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, .none, sha1_name));
}
test "sync §7.7 L1434-1435: an out-of-domain name WIDTH is answered `admit`, not asserted — a peer chooses this token" {
// `parseFileCard`/`parseCfileCard` validate arity and the numeric fields but
// NOT the hash width, so a peer can put any token in the HASH position and it
// reaches the predicate verbatim. Neither half can fire on such a name:
// shun-sha1 keys on a SHA1 NAME, and L1435-1437 keys the table on the
// COMPLETE name. An assert here would be a peer-triggered panic in a
// Debug/ReleaseSafe build (and would vanish in a release one).
var shun = FixtureShunSet{ .names = &.{s77_target} };
for ([_][]const u8{ "", "abc", "-", "a" ** 39, "a" ** 41, "a" ** 65 }) |odd| {
try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, shun.shunSet(), odd));
}
}
test "sync §7.7 L1434-1435 / §7.1: a `file` card whose HASH is not a hash width is malformed and stores nothing" {
// A short HASH token frames a perfectly legal payload, but it names no
// artifact: the parser rejects it, as Fossil does (`malformed file line`),
// before admission or the store ever sees it.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{s77_target} };
var client = Client{
.operation = .pull,
.state = .exchange,
.hash_policy = .shun_sha1,
.shun = shun.shunSet(),
};
defer client.deinit(gpa);
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(gpa, ms.store(), "file abc 3\nxyz"));
try std.testing.expect(ms.store().lookup("abc") == null);
try std.testing.expectEqual(@as(repostate.Rid, 0), ms.store().count());
}
test "sync §7.7 L1428-1432 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, .none, sha1_name));
}
try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, .none, sha1_name));
}
test "sync §7.7 L1465-1466 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 L1466-1471 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 L1467-1471 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 L1244-1246: 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 L1239-1240: 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" {
// §8.5.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 L964-967 / §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);
// One round: the uv card between the marker and the content it must
// not consume. (A uv-only reply is now an EMPTY round — the §3.7
// clean terminal — so a split-body variant would end the session.)
const obs = try client.consumeReply(gpa, ms.store(), "private\nuvigot a\\sb 1 - 0\nfile " ++ test_hash_a ++ " 5\nHELLO");
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped); // counted, not errored
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);
const combined = try std.mem.concat(gpa, u8, &.{ "private\nuvgimme a\\sb\n", cfile });
defer gpa.free(combined);
const obs = try client.consumeReply(gpa, ms.store(), combined);
try std.testing.expectEqual(@as(u64, 0), client.uv_skipped); // a request loses nothing here
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 client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n");
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped);
// A reply of only-declined cards is an EMPTY round: the §3.7 clean
// terminal, not an abort — the session ends having transferred all
// versioned content (none here).
try std.testing.expectEqual(ClientState.terminated, 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 client.consumeReply(gpa, ms.store(), "uvfile f 1 " ++ empty_hash ++ " 1 340282366920938463463374607431768211460\n");
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped);
try std.testing.expectEqual(ClientState.terminated, client.state); // empty round = clean stop
}
// 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 client.consumeReply(gpa, ms.store(), body);
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped); // counted, never an abort
try std.testing.expectEqual(ClientState.terminated, client.state); // empty round = clean stop
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 L1226 — 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. artifact §4.3 L657-663 makes the defossilize
// decoder error-free on an unrecognized escape (\q -> q, no error), and
// §5.2 L946 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 = [_]struct { body: []const u8, skipped: u64 }{
.{ .body = "uvgimme bad\\q\n", .skipped = 0 },
.{ .body = "uvfile bad\\q 1 - 0 0\n", .skipped = 1 },
.{ .body = "uvigot bad\\q 1 - 0\n", .skipped = 1 },
};
for (cases) |case| {
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(), case.body);
try std.testing.expectEqual(case.skipped, client.uv_skipped);
try std.testing.expectEqual(ClientState.terminated, client.state); // empty round = clean stop
}
}
test "sync §8.5.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 §8.5.1: catalog hash of an empty catalog is SHA-1(\"\")" {
const hash = uvCatalogHash(&.{});
try std.testing.expectEqualStrings("da39a3ee5e6b4b0d3255bfef95601890afd80709", &hash);
}
test "sync §8.5.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 §8.5.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 §8.5.1: status 0 when no local row exists" {
try std.testing.expectEqual(UvStatus.absent, uvStatus(null, 100, "abc"));
}
test "sync §8.5.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 §8.5.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 §8.5.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 §8.5.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 §8.5.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 §8.5.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 §8.5.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 §8.5.1: send table init and apply survive allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, uvSendTableNoLeak, .{});
}
test "sync §8.5.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 §8.5.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, L511-514 — 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 L513-514 — 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 L517-518 — "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 L537 — 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 L537-541 — 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{};
defer queue.deinit(std.testing.allocator);
try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = 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, .unrestricted);
// 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: duplicate retained targets keep HASH content-received until the LAST entry drains" {
// Two retained deltas may name the same target off different bases (a
// peer is free to send both). `hasTarget` is counted membership: the
// target stays content-received while ANY entry survives, and clears
// only when the last one completes.
var ms = repostate.MemStore.init(std.testing.allocator);
defer ms.deinit();
const store = ms.store();
const test_hash_c = "c" ** 40;
const src_a = "first basis bytes for the duplicated target artifact";
const src_b = "second basis bytes, different from the first entirely";
const target = "the shared target content both deltas reconstruct to!";
const d_a = try delta.create(std.testing.allocator, src_a, target);
defer std.testing.allocator.free(d_a);
const d_b = try delta.create(std.testing.allocator, src_b, target);
defer std.testing.allocator.free(d_b);
_ = try store.intern(test_hash_a); // the shared target
_ = try store.intern(test_hash_b); // basis 1
_ = try store.intern(test_hash_c); // basis 2
var queue = PendingDeltaQueue{};
defer queue.deinit(std.testing.allocator);
try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = d_a });
try queue.retain(std.testing.allocator, test_hash_a, test_hash_c, .{ .raw = d_b });
try std.testing.expect(queue.hasTarget(test_hash_a));
try std.testing.expectEqual(@as(usize, 2), queue.itemCount());
// The first basis arrives: one entry drains, the target STAYS
// content-received through the survivor.
_ = try store.put(test_hash_b, src_a);
try queue.completeFor(std.testing.allocator, store, test_hash_b, .unrestricted);
try std.testing.expectEqual(@as(usize, 1), queue.itemCount());
try std.testing.expect(queue.hasTarget(test_hash_a));
// The second arrives: the queue empties and membership clears.
_ = try store.put(test_hash_c, src_b);
try queue.completeFor(std.testing.allocator, store, test_hash_c, .unrestricted);
try std.testing.expectEqual(@as(usize, 0), queue.itemCount());
try std.testing.expect(!queue.hasTarget(test_hash_a));
}
test "sync §9.3: a strategy requests missing content only via igot+gimme (single rule surface)" {
// §8.5 L1633-1635 — 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" {
// §8.8 L1777-1779 + §3.3 L527-529 — 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" {
// §8.8 L1778-1779 + §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 L527 — the ordinary phantom scan excludes peer-lacks hashes.
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 L527-528 — the scan excludes names in the repository shun table.
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 L527-529 — the scan excludes private hashes unless 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 }));
}
test "sync §9.4: the eligibility filter is evaluated freshly each round" {
// §3.3 L516-517 — peer-lacks is per-round state. 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 L532 — 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 L535 — 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 L532-535 — 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)" {
// §8.9 L1783-1784 + §3.7 L603-607 — termination is the §3.7 rule, clone (e) 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 L621 + §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 L537-539), 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" {
// §8.9 L1783-1784 + §3.7 L621-622 — 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 L2469-2474 — 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 = rfc_vectors.sync_a1_round1_reply;
// §A.1 L2505-2516 — 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 = rfc_vectors.sync_a1_round2_reply;
// §A.2 L2542-2545 — SHA3 gate response, 191-byte body: error, clone_seqno 0,
// push, closing timestamp.
const a2_reply = rfc_vectors.sync_a2_reply;
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 (L2470-2471); the ancestor the
// server already holds (igot L2453, never gimme'd; P parent L2487).
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 => 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 private_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 L1376-1377 — 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 " ++ private_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 L1376-1377 — 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", .{ private_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 L1376-1377 — 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 " ++ private_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 §B L2863 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" {
// The delta-form file/cfile card is no longer here: §7.4 reception is now
// implemented (a dangling delta is retained, not fenced off). uvfile remains
// the staged, out-of-scope boundary.
const bodies = [_][]const u8{
"uvfile a.txt 1700000000 " ++ test_hash_a ++ " 12 4\n", // §7.3 uvfile, omitted content, still staged
};
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 client.consumeReply(std.testing.allocator, ms.store(), body);
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped); // counted, not errored
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 / §9.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 L567-568 — "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 L567-568 — "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 L567-570 — "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 L797-801 — "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: a cookie card with no TEXT token is a fatal arity abort (rejection)" {
// §B L2835 `cookie-card = "cookie" SP fossilized LF` — exactly two tokens.
// A bare `cookie\n` (arity 1) fails the card's argument check; §5.2 L1024-1026
// 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)" {
// §3.2 L399-403 — login is not among the cards a reply may contain (Table 2
// §3.2.1 L431 places it in requests); the abort is zit-local under §3.9 L797-803.
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 §9.3/§3.9: a reqconfig card in a server reply is a fatal abort (rejection)" {
// §9.3 L1883-1884 — "A client MAY issue multiple reqconfig groups in one request": 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 §9.3/§5.1: consumeReply frames a config payload by its SIZE byte count" {
// §9.3 L1888-1889 — `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 §9.3: a config card's trailing newline is consumed" {
// §9.3 L1888-1891 — "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 §9.3: dispatch resumes at start + SIZE + 1 after a config card" {
// §9.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 §9.3/§7: a config frame past the reply body is a fatal abort (rejection)" {
// §9.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 §9.3: a config card missing the trailing newline is a fatal abort (rejection)" {
// §9.3 L1888-1891 — "one mandatory LF" binds senders only; a receiver need not check that byte, so this rejection is zit-local:
// 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 §9.3: a malformed config header is a fatal abort (rejection)" {
// §9.3 L1888-1889 `config GROUPNAME SIZE` — each malformed header is a fatal
// abort: (a) too few tokens, (b) non-numeric SIZE, (c) SIZE > u32,
// (d) negative SIZE — the RFC defines no receiver fatal for it (§9.3
// L1923-1927 is server-side; §5.2 L1024-1026 warns), so this abort is zit-local.
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 L567-568, §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 L2835/§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 §9.3 fuzz: config byte-count framing survives an adversarial payload" {
// Invariant (§9.3 L1888-1891): 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 §5.1: consumeReply consumes the cfile trailing-LF padding when the payload does not end in LF" {
const gpa = std.testing.allocator;
// §7.5 L1374-1377 — a private cfile without the grant is discarded after
// CSIZE framing, before the container is decoded, so an arbitrary 4-byte
// payload that does NOT end in LF exercises §5.1 padding without needing a
// valid zlib container. The `private` marks the cfile private (discarded),
// the payload's last byte 'D' ≠ LF so §5.1 puts one padding LF on the wire,
// and a trailing `private` whose effect (private_pending) proves the loop
// parsed the card immediately AFTER the padding.
const body = "private\ncfile " ++ test_hash_a ++ " 4 4\nABCD\nprivate\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); // §5.1 — old code aborts on the padding empty line
try std.testing.expect(client.state != .aborted);
try std.testing.expect(client.private_pending); // the post-padding `private` card was parsed → padding consumed
}
test "sync §5.1: a cfile whose payload ends in LF carries no padding; the following card is parsed" {
const gpa = std.testing.allocator;
// payload "AB\n" ends in LF → §5.1 emits NO padding, so the receiver must
// NOT skip a byte; the immediately following `private` card must parse.
const body = "private\ncfile " ++ test_hash_a ++ " 3 3\nAB\nprivate\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);
try std.testing.expect(client.state != .aborted);
try std.testing.expect(client.private_pending); // no over-skip: the trailing `private` was parsed
}
test "sync §5.1: a cfile payload ending in LF does not consume a following empty line (negative space)" {
const gpa = std.testing.allocator;
// payload "AB\n" ends in LF (§5.1 → no padding). A bare empty line then
// follows: it is a malformed card the receiver MUST abort on, never silently
// skip. This pins the conditional to the payload's LAST byte — not merely to
// "is the next wire byte an LF".
const body = "private\ncfile " ++ test_hash_a ++ " 3 3\nAB\n\nprivate\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .sync, .state = .exchange };
defer client.deinit(gpa);
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
}
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 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 §8.2 L1529-1539: a mid-clone cfile batch sets the received-artifact observation that continues the clone" {
const gpa = std.testing.allocator;
const plaintext = "batch-boundary 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);
// A non-final batch: one cfile then a positive `clone_seqno NEXT` — the
// server still has artifacts to page (§8.2 L1517-1520).
const body = try std.fmt.allocPrint(gpa, "{s}clone_seqno 7\n", .{cfile});
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// A follow-up (non-first) clone round is consumed in `exchange`, where §3.7
// gates continuation on the observation this reply produces (the first
// round advances unconditionally and never reaches this predicate).
var client = Client{ .operation = .clone, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
// §8.2 L1529-1532 — the raw received-artifact count grew and a delivery was
// stored; the cursor NEXT was recorded verbatim.
try std.testing.expect(obs.artifact_count_grew);
try std.testing.expect(obs.artifact_received_this_round);
try std.testing.expectEqual(@as(i64, 7), obs.clone_seqno_next);
// §3.7(e)/§8.2 L1537-1539 — a stored delivery this round with a positive cursor
// continues the clone; the driver threads NEXT into the next `clone 3 7`.
try std.testing.expect(continueExchange(obs));
try std.testing.expectEqual(ClientState.exchange, client.state);
}
test "sync §8.2 L1520/L1541: a clone reply whose cursor is zero and stored nothing terminates the clone" {
const gpa = std.testing.allocator;
// A cleanup-shaped clone reply: no cfile, `clone_seqno 0` (exhausted). No
// stored delivery and a zero cursor — §3.7(e) finds nothing to continue.
const body = "clone_seqno 0\n";
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .clone, .state = .exchange };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(!obs.artifact_received_this_round);
try std.testing.expect(!obs.artifact_count_grew);
try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next);
try std.testing.expect(!continueExchange(obs)); // §8.2 L1541-1542 — NEXT zero bars further clone
try std.testing.expectEqual(ClientState.terminated, client.state);
}
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/§7.7/§5.2: a full-form cfile whose container fails to inflate is retained at receive, not a receive-time abort" {
const gpa = std.testing.allocator;
// §7.2 L1214-1218 — USIZE "is parsed syntactically but not transfer-layer
// validated"; the payload "is the repository's stored compressed full
// artifact... retransmitted verbatim". §7.7 L1465-1471 — integrity is a
// persist-commit-hook obligation that hashes the RECONSTRUCTED bytes, and a
// clone-context cfile is EXCLUDED from any pre-storage transfer-layer check.
// §5.2 L968-970 — a locally-malformed reply card is a local warning that stops
// only the current reply-card loop, NOT a fatal abort. So a full-form (non-
// delta) container whose declared length exceeds the read cap (OutputTooLarge)
// or whose RFC1950 stream is corrupt (InvalidStream) is ACCEPTED and retained
// verbatim at receive; the corruption surfaces only later at read/derivation
// (content() cannot inflate → null), never as a receive-time clone abort. These
// are the decompression-class payloads the pre-optimization receiver aborted on
// by inflating every full payload to discard — that over-strict inflate is gone.
const payloads = [_][]const u8{
"\xff\xff\xff\xff\x00", // §4.1 frame present; declared length » read cap → OutputTooLarge at read
"\x00\x00\x00\x04\x78\x9c\xff\xff", // §4.1 frame present; corrupt RFC1950 stream → InvalidStream at read
};
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 = .clone, .state = .clone_round0 };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body); // §5.2 — no receive-time abort
try std.testing.expect(client.state != .aborted);
try std.testing.expect(obs.versioned_content_delivered); // §3.7(a)/§8.2 r14 — counted like a full file card
const rid = ms.store().lookup(test_hash_a).?; // HASH interned (phantom upgraded)
// §7.7 — content-addressed corruption surfaces at read/derivation (the
// capped inflate fails → null), not as a receive-time clone abort.
try std.testing.expectEqual(@as(?[]const u8, null), ms.store().content(rid));
}
}
test "sync §7.4/§5.2: a pending pull .raw delta whose basis arrives as a CORRUPT full-form cfile aborts the round, never traps" {
// §7.4 basis precondition (W4 regression). Pre-W4 a corrupt full container
// aborted at receive, so it could never become a delta basis — the "basis
// content is materialisable" precondition always held. W4 (§7.2/§7.7) now
// retains a stored full cfile container VERBATIM and defers §7.7 inflate/
// verify to read, so a corrupt full container is interned+retained with a null
// content(). A malicious server can exploit that: send a pull `.raw` file-card
// delta naming basis H (retained dangling, §7.4-r1), THEN send H as a full-form
// cfile whose container fails to inflate. When completeFor drives the dependent
// delta, its basis cannot be materialised. §5.2 — that surfaces as a graceful
// round-abort at the dependent delta, NOT a `content() orelse unreachable` trap
// (the remote-triggerable DoS the pre-fix line 5140 would panic on).
const gpa = std.testing.allocator;
// A well-framed raw [FossilDelta] for the file card; its bytes are never
// applied (the round aborts when the basis proves un-materialisable), so the
// basis it was cut against is immaterial — only its wire framing matters.
const d = try delta.create(gpa, "some basis plaintext", "some basis plaintext, extended");
defer gpa.free(d);
// §7.2/§7.7 corrupt full container: §4.1 frame present (>=4 bytes) but the
// declared length dwarfs the read cap → OutputTooLarge at read → content()==null.
// The same decompression-class fixture the retain-verbatim receive test uses.
const corrupt = "\xff\xff\xff\xff\x00";
// Card order IS the attack: the dangling `.raw` delta (basis H = test_hash_b)
// is retained FIRST, then H arrives as the corrupt full-form cfile which drives
// completeFor over the pending entry (sync.zig:778 → completeEntry `.raw`).
const body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}cfile {s} 4 {d}\n{s}", .{
test_hash_a, test_hash_b, d.len, d, test_hash_b, corrupt.len, corrupt,
});
defer gpa.free(body);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .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); // §5.2 graceful round-abort, not a trap
}
test "sync §7.4/§5.2: a held-but-uninflatable full-form cfile that is gimme'd is skipped on the upload path, never trapped" {
// BLOCKER (W4 upload-path regression). W4 (§7.2/§7.7) retains a stored full
// cfile container VERBATIM and defers §7.7 inflate/verify to read, so a
// corrupt full container is interned+retained with `held()==true` but
// `content()==null`. A malicious server can weaponise that on the UPLOAD
// path in a single session: send H as a full-form cfile whose container
// fails to inflate (retained wire-backed, content()==null), THEN `gimme H`.
// The request composer keys its send buffer on `held()` (§3.3 L532-533) and
// then does `content(rid).?` (§3.2 L412-413) — a null-deref/assert trap on
// the corrupt container. §3.3 — a gimme this side cannot MATERIALISE for
// upload must fold to the same byte-neutral silent-skip as a gimme for an
// unheld hash (gimmeResponse `.silent_skip`): not queued, no corrupt bytes
// sent, the request built cleanly.
const gpa = std.testing.allocator;
// §7.2/§7.7 corrupt full container: §4.1 frame present (>=4 bytes) but the
// declared length dwarfs the read cap → OutputTooLarge at read → content()==null.
// The same decompression-class fixture the retain-verbatim receive test uses.
const corrupt = "\xff\xff\xff\xff\x00";
// Card order IS the attack: retain H wire-backed FIRST (corrupt full cfile),
// then `gimme H` drives the upload-queue decision over the held-but-null rid.
const body = try std.fmt.allocPrint(gpa, "cfile {s} 4 {d}\n{s}gimme {s}\n", .{
test_hash_a, corrupt.len, corrupt, test_hash_a,
});
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); // §5.2 — no receive-time abort
try std.testing.expect(client.state != .aborted);
const rid = ms.store().lookup(test_hash_a).?; // H interned, retained wire-backed
try std.testing.expect(ms.store().held(rid)); // §7.2 — held via its container
try std.testing.expectEqual(@as(?[]const u8, null), ms.store().content(rid)); // §7.7 — but not reconstructable
// §3.3 silent-skip precedent — a gimme this side cannot materialise is not
// queued for upload (byte-neutral, exactly as an unheld-hash gimme).
try std.testing.expect(!obs.content_queued);
try std.testing.expectEqual(@as(usize, 0), client.send_queue.count());
// §3.2 — composing the next request drives the upload path (drainSendQueue →
// file-card composer). It must complete GRACEFULLY (the corrupt container is
// skipped), never trap on `content(rid).?`.
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
_ = try client.buildRequest(gpa, ms.store(), &out);
try std.testing.expectEqual(@as(usize, 0), client.content_sent); // nothing uploaded
}
test "sync §4.1/§3.9: a full-form cfile payload too short to carry the §4.1 length prefix is a fatal framing error" {
const gpa = std.testing.allocator;
// Distinct from the decompression-class corruption above (§7.2/§7.7 — retained
// then read-verified): a payload under 4 bytes cannot carry the §4.1 4-byte
// length prefix, so it is not a container frame at all. That is a framing
// violation (§3.9 fatal), the one full-form receive-time reject the retain-
// verbatim path keeps — retaining it would break `retain`'s container.len >= 4
// §4.1 precondition.
const body = try buildCfileBody(gpa, test_hash_a, 4, 2, "\x00\x01");
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/§7.4: a delta-form cfile against a held basis reconstructs and stores under HASH" {
// §7.2-r13 — the cfile delta form carries a stored COMPRESSED delta; after
// §4.1 container decode the bytes are the [FossilDelta] against DELTASRC,
// reconstructed on a held basis (§7.4) and stored under HASH.
const gpa = std.testing.allocator;
const basis = "the basis artifact bytes for a cfile-delta reconstruction";
const target = "the basis artifact bytes for a cfile-delta reconstruction, extended";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
const enc = try zlibContainerEncode(gpa, d, .default); // §4.1 stored compressed delta
defer gpa.free(enc);
var target_buf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &target_buf);
var basis_buf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &basis_buf);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(basis_hash, basis); // basis already held
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
const body = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n{s}", .{ target_hash, basis_hash, target.len, enc.len, enc });
defer gpa.free(body);
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(target_hash).?;
try std.testing.expectEqualStrings(target, ms.store().content(rid).?);
}
// §7.4/§3.3 — delta-form `file` reception through consumeReply: the handler
// wiring (parse → receiveDelta → PendingDeltaQueue → gimme exclusion), with the
// RFC-worked deltas as the oracle. Expected values are the reconstructed
// artifact bytes and their SHA3-256 names, never fossil-captured.
test "sync §7.4: a delta-form file against a held basis reconstructs and stores under HASH" {
// §7.1-r5/§7.4 — the file delta form carries the [FossilDelta] uncompressed;
// a held basis reconstructs the target immediately, stored under HASH whose
// §7.7 integrity holds over the reconstructed bytes.
const gpa = std.testing.allocator;
const basis = "delta-file basis content, the source the delta is taken against";
const target = "delta-file basis content, the source the delta is taken against, plus a tail";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(basis_hash, basis);
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
const body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ target_hash, basis_hash, d.len, d });
defer gpa.free(body);
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(obs.versioned_content_delivered);
try std.testing.expect(client.state != .aborted);
try std.testing.expectEqualStrings(target, ms.store().content(ms.store().lookup(target_hash).?).?);
try std.testing.expectEqual(@as(usize, 0), client.pending.itemCount());
}
// §7.1/§7.4/§7.7 — delta-form `file` reception on the SERVER push-ingest path
// (`Server.respond`), the mirror of the client tests above. A real `fossil push`
// sends the pushed manifest as `file HASH DELTASRC SIZE` delta-encoded against a
// basis the server advertised holding (§3.2 igot); these prove the server
// reconstructs it against the held basis, §7.7-verifies, and stores it — with the
// RFC-built delta as the oracle, never fossil-captured bytes.
test "sync §7.4: a pushed delta against a basis delivered EARLIER in the same round lands both" {
// fossil pushes a batch of check-ins as one round, each manifest a delta
// against the previous one. The basis is not held when the delta card is
// scanned; it is a card of the round. Any multi-commit push from a stock
// fossil failed with "malformed file line" until the scan learned this.
const gpa = std.testing.allocator;
const basis = "manifest one, sent in full at the head of the round";
const target = "manifest one, sent in full at the head of the round, then edited";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {d}\n{s}file {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, basis_hash, basis.len, basis, target_hash, basis_hash, d.len, d });
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
const b_rid = ss.store().lookup(basis_hash) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(basis, ss.store().content(b_rid).?);
const t_rid = ss.store().lookup(target_hash) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(target, ss.store().content(t_rid).?);
}
test "sync §7.4: a pushed delta against a basis delivered LATER in the same round lands both, through a chain" {
// Order within the round is the sender's business. Here the round is
// delta-of-delta first, its basis delta second, and the full basis last;
// the scan parks both deltas and settles them at the fixpoint.
const gpa = std.testing.allocator;
const a = "the full basis, sent last";
const b = "the full basis, sent last, edited once";
const c = "the full basis, sent last, edited once, and again";
const d_ab = try delta.create(gpa, a, b);
defer gpa.free(d_ab);
const d_bc = try delta.create(gpa, b, c);
defer gpa.free(d_bc);
var abuf: [64]u8 = undefined;
const a_hash = artifact.artifactName(.sha3_256, a, &abuf);
var bbuf: [64]u8 = undefined;
const b_hash = artifact.artifactName(.sha3_256, b, &bbuf);
var cbuf: [64]u8 = undefined;
const c_hash = artifact.artifactName(.sha3_256, c, &cbuf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}file {s} {s} {d}\n{s}file {s} {d}\n{s}", .{
h2_servercode, h2_projectcode,
c_hash, b_hash,
d_bc.len, d_bc,
b_hash, a_hash,
d_ab.len, d_ab,
a_hash, a.len,
a,
});
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
inline for (.{ .{ a_hash, a }, .{ b_hash, b }, .{ c_hash, c } }) |want| {
const rid = ss.store().lookup(want[0]) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(want[1], ss.store().content(rid).?);
}
}
test "sync §7.1/§7.4/§7.7: server respond reconstructs a delta-form pushed file against a held basis and stores it under HASH" {
const gpa = std.testing.allocator;
const basis = "server-held basis manifest the pushed delta is encoded against";
const target = "server-held basis manifest the pushed delta is encoded against, plus the pushed check-in tail";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
_ = try ss.store().put(basis_hash, basis); // §3.2 — the server already holds the basis
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §7.1 — a push carrying the delta-form file card, exactly fossil's shape.
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d });
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error ")); // accepted, not a §5.2 error card
const rid = ss.store().lookup(target_hash) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(target, ss.store().content(rid).?); // §7.4 reconstructed + stored
try std.testing.expect(artifact.verify(target_hash, ss.store().content(rid).?)); // §7.7 integrity holds
// §3.8 — stateless: a second endpoint with the same held basis and the same
// request reaches the identical stored result (same request + same store).
var ss2 = repostate.MemStore.init(gpa);
defer ss2.deinit();
_ = try ss2.store().put(basis_hash, basis);
var out2: std.ArrayListUnmanaged(u8) = .empty;
defer out2.deinit(gpa);
_ = try server.respond(gpa, ss2.store(), request, &out2);
const rid2 = ss2.store().lookup(target_hash) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(target, ss2.store().content(rid2).?);
}
test "sync §7.7: server respond rejects a delta-form pushed file whose reconstruction hashes to the wrong HASH, store untouched" {
// §7.7 — pre-storage integrity hashes the reconstructed bytes: a delta that
// reconstructs to bytes not matching the announced HASH is rejected, and the
// fault stops the scan before pass-2 ingest, so nothing lands in the store.
const gpa = std.testing.allocator;
const basis = "server-held basis for the wrong-hash integrity rejection case";
const target = "server-held basis for the wrong-hash integrity rejection case, extended payload";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
// A HASH that is NOT the name of the reconstructed `target`, and not held
// either: a held HASH is a duplicate, consumed before reconstruction.
var wbuf: [64]u8 = undefined;
const wrong_hash = artifact.artifactName(.sha3_256, "a name nothing here reconstructs to", &wbuf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
_ = try ss.store().put(basis_hash, basis);
const held_before = ss.store().count();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, wrong_hash, basis_hash, d.len, d });
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(std.mem.startsWith(u8, reply, "error malformed\\sfile")); // §5.2 framing
try std.testing.expectEqual(held_before, ss.store().count()); // §7.7 — no new artifact interned/stored
}
test "sync §7.4: a pushed delta whose source is absent is not an error; the source becomes a phantom the reply gimmes" {
// §7.4 erratum — Fossil resolves DELTASRC before storing, creating a
// phantom when it is absent, and its end-of-request phantom scan asks for
// it: `gimme DELTASRC` with `errors 0`. The stateless server (§3.8) cannot
// park the delta itself, so the target does not land this round; the
// client's own igot of the target brings it back once the source arrives.
const gpa = std.testing.allocator;
const basis = "a basis the server was never given, so it cannot be resolved";
const target = "a basis the server was never given, so it cannot be resolved, with a tail";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf); // NOT stored
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d });
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
const gimme = try std.fmt.allocPrint(gpa, "gimme {s}\n", .{basis_hash});
defer gpa.free(gimme);
try std.testing.expect(std.mem.indexOf(u8, reply, gimme) != null);
const src_rid = ss.store().lookup(basis_hash) orelse return error.TestUnexpectedResult;
try std.testing.expect(!ss.store().held(src_rid)); // a phantom
try std.testing.expect(ss.store().lookup(target_hash) == null); // target not landed
}
test "sync §7.4: a pushed delta duplicating a held artifact is consumed and ignored, whatever its source" {
// §7.4 erratum — "If HASH already names a locally verified artifact, the
// receiver consumes and ignores the duplicate card's payload ... A
// receiver MAY check HASH first and skip delta-source handling." The
// source here is absent; checking HASH first means it is never looked
// up, so the duplicate costs no error, no phantom and no gimme.
const gpa = std.testing.allocator;
const basis = "an unrelated text the server does not hold";
const target = "the check-in manifest the server already holds, verbatim";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const target_rid = try ss.store().put(target_hash, target);
const held_before = ss.store().count();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d });
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
try std.testing.expect(std.mem.indexOf(u8, reply, basis_hash) == null);
try std.testing.expectEqual(held_before, ss.store().count());
try std.testing.expectEqualStrings(target, ss.store().content(target_rid).?);
}
test "sync §7.4/§3.3: a dangling delta is retained, not rejected, and its target is not re-gimme'd; the basis completes it" {
// §3.3 L528-532 — "For a dangling delta, retain the received delta under
// HASH, delete HASH's phantom, create DELTASRC's phantom, and set
// content-received; HASH is not requested again." OPTION B derives
// content-received via pending.hasTarget, so a target that was an igot
// phantom drops out of gimme while the basis phantom stays eligible.
const gpa = std.testing.allocator;
const basis = "dangling-delta basis, arrives in a later round to complete the target";
const target = "dangling-delta basis, arrives in a later round to complete the target, extended";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// An igot first makes the target a genuine store phantom (gimme-eligible).
const igot_body = try std.fmt.allocPrint(gpa, "igot {s}\n", .{target_hash});
defer gpa.free(igot_body);
_ = try client.consumeReply(gpa, ms.store(), igot_body);
try std.testing.expect(ms.store().content(ms.store().lookup(target_hash).?) == null);
// The dangling delta arrives (basis absent): retained, not rejected.
const d_body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ target_hash, basis_hash, d.len, d });
defer gpa.free(d_body);
const obs = try client.consumeReply(gpa, ms.store(), d_body);
try std.testing.expect(client.state != .aborted); // §7.4 MUST NOT reject
try std.testing.expect(obs.phantom_created); // §3.7(a) DELTASRC phantom
try std.testing.expect(client.pending.hasTarget(target_hash)); // content-received
try std.testing.expect(ms.store().content(ms.store().lookup(basis_hash).?) == null); // basis phantom
// §3.3 — the next request gimmes the basis but NOT the content-received
// target. The basis is the only phantom (content null), so it is gimme'd; the
// target's hash never appears (neither gimme nor igot).
const req = try client.buildRequest(gpa, ms.store(), &out);
const gimme_basis = try std.fmt.allocPrint(gpa, "gimme {s}", .{basis_hash});
defer gpa.free(gimme_basis);
try expectHasLine(req, gimme_basis); // gimme DELTASRC
try std.testing.expect(std.mem.indexOf(u8, req, target_hash) == null); // not re-requested
// The basis arrives as full content → completion reconstructs the target.
const basis_body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ basis_hash, basis.len, basis });
defer gpa.free(basis_body);
_ = try client.consumeReply(gpa, ms.store(), basis_body);
try std.testing.expectEqualStrings(target, ms.store().content(ms.store().lookup(target_hash).?).?);
try std.testing.expectEqual(@as(usize, 0), client.pending.itemCount());
}
test "artifact-format §3.2 L347-350: a DELTASRC of another width is malformed — the peer never chooses the phantom set's key domain" {
// THE KEY-DOMAIN BOUNDARY, from the direction a peer controls. `ShunSet` and
// `PendingDeltaQueue` are keyed by COMPLETE artifact name, and a dangling
// delta is the third way a name enters the phantom set — after `igot` and
// `gimme`, both of which reject any other width as malformed. But
// `parseFileCard`/`parseCfileCard` validate arity and the byte counts only, so
// before the gate in `receiveDelta` a `file HASH zz SIZE` card interned "zz"
// as a phantom and every subsequent §3.3 scan asked the shun table and the
// pending queue about it — one card, one crashed `zit pull`, in EVERY
// repository (the shun table need not even exist). The card is rejected
// instead, and the store is left with no such name.
const gpa = std.testing.allocator;
const basis = "the basis this peer names with an abbreviated, out-of-domain token";
const target = "the basis this peer names with an abbreviated, out-of-domain token, extended";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
const short_src = "zz"; // neither 40 nor 64 hex
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// `.none` on purpose: nothing about this depends on a table existing.
var client = Client{ .operation = .pull, .state = .exchange, .shun = ShunSet.none };
defer client.deinit(gpa);
const body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ target_hash, short_src, d.len, d });
defer gpa.free(body);
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expect(ms.store().lookup(short_src) == null);
try std.testing.expectEqual(@as(usize, 0), client.pending.itemCount());
// And the filter itself ANSWERS an out-of-domain width rather than trapping —
// the interface's own published contract, kept independent of which boundaries
// happen to guard it today.
try std.testing.expect(!ShunSet.none.contains(short_src));
try std.testing.expect(!ShunSet.none.contains(""));
// Negative space: the same card with a COMPLETE DELTASRC is the ordinary
// dangling delta, retained rather than rejected.
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ok_ms = repostate.MemStore.init(gpa);
defer ok_ms.deinit();
var ok = Client{ .operation = .pull, .state = .exchange, .shun = ShunSet.none };
defer ok.deinit(gpa);
const ok_body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ target_hash, basis_hash, d.len, d });
defer gpa.free(ok_body);
_ = try ok.consumeReply(gpa, ok_ms.store(), ok_body);
try std.testing.expect(ok.state != .aborted);
try std.testing.expect(ok_ms.store().lookup(basis_hash) != null);
}
test "sync §7.4: a chained delta reception completes to a fixpoint when the chain root arrives (C<-A<-B)" {
// The filed §7.4 erratum: completion is recursive. C (basis A) and A (basis
// B) both arrive before B; storing B completes A, and storing A completes C.
// A direct-only receiver would leave C a permanent phantom.
const gpa = std.testing.allocator;
const b_bytes = "chain root B, the on-wire full-content basis of the whole chain";
const a_bytes = "chain root B, the on-wire full-content basis of the whole chain, then A";
const c_bytes = "chain root B, the on-wire full-content basis of the whole chain, then A, then C";
const d_a = try delta.create(gpa, b_bytes, a_bytes);
defer gpa.free(d_a);
const d_c = try delta.create(gpa, a_bytes, c_bytes);
defer gpa.free(d_c);
var ab: [64]u8 = undefined;
var bb: [64]u8 = undefined;
var cb: [64]u8 = undefined;
const a_hash = artifact.artifactName(.sha3_256, a_bytes, &ab);
const b_hash = artifact.artifactName(.sha3_256, b_bytes, &bb);
const c_hash = artifact.artifactName(.sha3_256, c_bytes, &cb);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
// C then A arrive dangling (bases A then B still absent).
const c_body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ c_hash, a_hash, d_c.len, d_c });
defer gpa.free(c_body);
_ = try client.consumeReply(gpa, ms.store(), c_body);
const a_body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ a_hash, b_hash, d_a.len, d_a });
defer gpa.free(a_body);
_ = try client.consumeReply(gpa, ms.store(), a_body);
try std.testing.expectEqual(@as(usize, 2), client.pending.itemCount());
// B arrives full-content → cascade A then C to fixpoint.
const b_body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ b_hash, b_bytes.len, b_bytes });
defer gpa.free(b_body);
_ = try client.consumeReply(gpa, ms.store(), b_body);
try std.testing.expectEqualStrings(a_bytes, ms.store().content(ms.store().lookup(a_hash).?).?);
try std.testing.expectEqualStrings(c_bytes, ms.store().content(ms.store().lookup(c_hash).?).?);
try std.testing.expectEqual(@as(usize, 0), client.pending.itemCount()); // no permanent phantom
}
test "sync §7.4/§7.7: a delta that reconstructs to the wrong hash is rejected (fatal)" {
// §7.7 — integrity hashes the reconstructed bytes; a non-clone file whose
// reconstruction does not hash to HASH is rejected before storage, never
// landing bytes under a name they do not match.
const gpa = std.testing.allocator;
const basis = "wrong-hash basis content for the delta reconstruction check";
const target = "wrong-hash basis content for the delta reconstruction check, appended";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
const wrong_hash = "a" ** 64; // a valid 64-hex name that is NOT the target's
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(basis_hash, basis);
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
const body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ wrong_hash, basis_hash, d.len, d });
defer gpa.free(body);
try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body));
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expect(ms.store().lookup(wrong_hash) == null); // nothing stored under the wrong name
}
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/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 => continue,
};
}
}
}
// ---------------------------------------------------------------------------
// §7.5 — Private-content receive-side gates through consumeReply.
// ---------------------------------------------------------------------------
test "sync §7.5 L1376-1377: 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 L1376-1377: 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 L1376-1377: 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 L1376-1377: 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);
// One round: marker, declined uv card, then the content the marker binds.
const obs = try client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n" ++ "file " ++ test_hash_a ++ " 5\nHELLO");
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped); // counted, not errored
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);
const combined = try std.mem.concat(gpa, u8, &.{ "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n", cfile });
defer gpa.free(combined);
const obs = try client.consumeReply(gpa, ms.store(), combined);
try std.testing.expectEqual(@as(u64, 1), client.uv_skipped); // counted, not errored
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 L1374-1376: 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 L1374-1376: an unauthorized private igot for an unknown hash creates no phantom" {
const gpa = std.testing.allocator;
// §7.5 L1374-1376 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 L1381-1382: an authorized private igot for an unknown hash creates a phantom to pull it" {
const gpa = std.testing.allocator;
// §7.5 L1381-1382 — 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 L1379-1380: 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 L1379-1380 / §3.3 L527-529: 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 L1375-1376: 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 L1376-1377: send-private is emitted only when the client holds Private" {
// §7.5 L1376-1377 — pulling private content MUST be preceded by a send-
// private the client is entitled to send; §9.5 L1983-1984 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)));
}
// ---------------------------------------------------------------------------
// A0 transport half 2 — the stateless Server.respond. Every expected value is
// transcribed from the sync RFC and its Appendix A worked exchange, never from
// running fossil.
// ---------------------------------------------------------------------------
// §A.1 L2443-2473 — the two repositories share this project code and the five
// artifact hashes of the worked push/pull convergence.
const h2_servercode = "da3e69ab0655a7dd2aba2520b2d3302a97c85787";
const h2_projectcode = "aa87ea41b0bb05ba528f7a3bdba700d7e23d6318";
const h2_manifest_x = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
const h2_alpha = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15";
const h2_ancestor = "c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57";
const h2_manifest_y = "4361252d0f450879dcd2e0b6a42b95ffe8a41d8e948130253ae8eefa7f4b79ea";
const h2_beta = "f5116517775075ea483901021282ba97eebe5a2b993a47eb78198adc10efe128";
// §A.1 L2448 / §10.1 — a dated client/server version pragma pair.
const h2_server_version = VersionInfo{ .version = 22800, .datetime = .{ .date = 20260311, .time = 113146 } };
// §A.1 L2448-2454 — the client's Round 1 request body, transcribed verbatim.
const h2_round1_request =
"pragma client-version 22800 20260311 113146\n" ++
"pull " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\n" ++
"push " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\n" ++
"igot " ++ h2_manifest_x ++ "\n" ++
"igot " ++ h2_alpha ++ "\n" ++
"igot " ++ h2_ancestor ++ "\n" ++
"# F2A895EF75B47B7157664A7FEAC978EFFFDB01C4\n";
// §A.1 L2469-2473 — a store seeded with the server's Round 1 holdings: the
// shared ancestor plus the two artifacts only it holds.
fn h2ServerHoldings(ms: *repostate.MemStore) !void {
_ = try ms.store().put(h2_ancestor, "shared ancestor manifest");
_ = try ms.store().put(h2_manifest_y, "server checkin manifest");
_ = try ms.store().put(h2_beta, "beta content added server-side\n");
}
test "sync §A.1: respond gimmes what the server lacks, igots its extras, and never gimmes content it already holds" {
const gpa = std.testing.allocator;
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
try h2ServerHoldings(&ss);
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), h2_round1_request, &out);
// §A.1 L2470-2471 / §3.3 L511-518 — the server interned the client's two
// unheld igots as phantoms and requests their content with gimme.
try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ h2_manifest_x) != null);
try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ h2_alpha) != null);
// §A.1 L2472-2473 / §8.6 L1753-1754 — the two holdings the client did not
// announce are advertised with igot.
try std.testing.expect(std.mem.indexOf(u8, reply, "igot " ++ h2_manifest_y) != null);
try std.testing.expect(std.mem.indexOf(u8, reply, "igot " ++ h2_beta) != null);
// §A.1 L2469-2474 — the ancestor the server already holds is never gimme'd.
try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ h2_ancestor) == null);
}
test "sync §3.8: respond is stateless — a repeat over the same store and request yields identical bytes" {
const gpa = std.testing.allocator;
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
try h2ServerHoldings(&ss);
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §3.1 L262-264 — only the client runs a state machine. The first call
// interns the client's igots as phantoms; the second must re-derive the same
// bytes, proving the phantom interning is idempotent and no Server field
// carries state across the two calls.
const first = try server.respond(gpa, ss.store(), h2_round1_request, &out);
const first_copy = try gpa.dupe(u8, first);
defer gpa.free(first_copy);
const second = try server.respond(gpa, ss.store(), h2_round1_request, &out);
try std.testing.expectEqualStrings(first_copy, second);
}
test "sync §10.1: respond emits server-version for a dated client-version and suppresses it for a bare one" {
const gpa = std.testing.allocator;
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §9.1 L1854-1856 (r11/r12) — DATE and TIME present ⇒ server-version replies.
const dated = try server.respond(gpa, ss.store(), "pragma client-version 22800 20260311 113146\n", &out);
try std.testing.expect(std.mem.indexOf(u8, dated, "pragma server-version") != null);
// §9.1 L1857-1859 — a VERSION-only client-version suppresses server-version.
const bare = try server.respond(gpa, ss.store(), "pragma client-version 22800\n", &out);
try std.testing.expect(std.mem.indexOf(u8, bare, "pragma server-version") == null);
}
test "sync §6.2/§6.4: a login built by buildRequest verifies against the same stored secret with no error card" {
const gpa = std.testing.allocator;
// §6.3 L1091-1095 — both endpoints hold the same derived shared secret.
const secret: [40]u8 = deriveSharedSecret(h2_projectcode, "alice", "correct-horse");
var cs = repostate.MemStore.init(gpa);
defer cs.deinit();
_ = try cs.store().put(h2_alpha, "alpha content from client\n");
var client = Client{
.operation = .sync,
.self_servercode = h2_servercode,
.project_code = h2_projectcode,
.version = h2_server_version,
.credential = .{ .user = "alice", .secret_hex = secret },
};
defer client.deinit(gpa);
var req_buf: std.ArrayListUnmanaged(u8) = .empty;
defer req_buf.deinit(gpa);
const request = try client.buildRequest(gpa, cs.store(), &req_buf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version, .credential = .{ .user = "alice", .secret_hex = secret } };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §6.4 L1137-1146 — the nonce covers exactly the post-login body, so the
// server's independent SIGNATURE reconstruction matches: no error card.
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
}
test "sync §6.4: a login whose SIGNATURE verifies against a different stored secret is rejected with an error card" {
const gpa = std.testing.allocator;
const client_secret: [40]u8 = deriveSharedSecret(h2_projectcode, "alice", "correct-horse");
const server_secret: [40]u8 = deriveSharedSecret(h2_projectcode, "alice", "wrong-password");
var cs = repostate.MemStore.init(gpa);
defer cs.deinit();
_ = try cs.store().put(h2_alpha, "alpha content from client\n");
var client = Client{
.operation = .sync,
.self_servercode = h2_servercode,
.project_code = h2_projectcode,
.version = h2_server_version,
.credential = .{ .user = "alice", .secret_hex = client_secret },
};
defer client.deinit(gpa);
var req_buf: std.ArrayListUnmanaged(u8) = .empty;
defer req_buf.deinit(gpa);
const request = try client.buildRequest(gpa, cs.store(), &req_buf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version, .credential = .{ .user = "alice", .secret_hex = server_secret } };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §6.4 L1152-1153 / §5.2 L1019-1022 — the reconstructed SIGNATURE differs,
// so the server resets its output and replies with an error card.
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(std.mem.startsWith(u8, reply, "error "));
}
test "sync §6.1: a request carrying two login cards is an authentication-fatal error card" {
const gpa = std.testing.allocator;
const hex40 = "0123456789abcdef0123456789abcdef01234567";
// §6.1 L1078-1079 — more than one login card in one request is fatal.
const request =
"pragma client-version 22800\n" ++
"login alice " ++ hex40 ++ " " ++ hex40 ++ "\n" ++
"login bob " ++ hex40 ++ " " ++ hex40 ++ "\n";
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
// §6.1 L1078-1079 — the server MUST reset its partial output and emit an
// error card: the reply is the error card alone, no announcement cards.
try std.testing.expect(std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(std.mem.indexOf(u8, reply, "gimme ") == null);
try std.testing.expect(std.mem.indexOf(u8, reply, "igot ") == null);
}
test "sync §5.2: an unknown request operator is answered with an error bad command card" {
const gpa = std.testing.allocator;
// §5.2 L969-971 — an unknown card operator is fatal; the server resets its
// output and emits `error bad command: LINE` (L1815-1817, Appendix C).
const request = "pragma client-version 22800\nbogus\n";
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expectEqualStrings("error bad\\scommand:\\sbogus\n", reply);
}
test "sync §5.2/§7.1: a malformed file card is answered with an error malformed file line card" {
const gpa = std.testing.allocator;
// §7.1 — a full-content file card is `file HASH SIZE`; a two-token card has
// an arity failure the server reports as `error malformed file line`
// (§5.2 L1013-1016) after resetting its output.
const request = "pragma client-version 22800\nfile deadbeef\n";
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expectEqualStrings("error malformed\\sfile\\sline\n", reply);
}
test "sync §3.7/§A.1: buildRequest/respond/consumeReply converge two endpoints to the union of their holdings" {
const gpa = std.testing.allocator;
// §A.1 L2451-2453/L2472-2473 — client holds X = {manifest_x, alpha}, server holds
// Y = {manifest_y, beta}, both share the ancestor cloned earlier.
var cs = repostate.MemStore.init(gpa);
defer cs.deinit();
_ = try cs.store().put(h2_ancestor, "shared ancestor manifest");
_ = try cs.store().put(h2_manifest_x, "client checkin manifest");
_ = try cs.store().put(h2_alpha, "alpha content from client\n");
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
_ = try ss.store().put(h2_ancestor, "shared ancestor manifest");
_ = try ss.store().put(h2_manifest_y, "server checkin manifest");
_ = try ss.store().put(h2_beta, "beta content added server-side\n");
var client = Client{
.operation = .sync,
.state = initialState(.sync),
.self_servercode = h2_servercode,
.project_code = h2_projectcode,
.version = h2_server_version,
};
defer client.deinit(gpa);
const server = Server{ .version = h2_server_version };
var req_buf: std.ArrayListUnmanaged(u8) = .empty;
defer req_buf.deinit(gpa);
var reply_buf: std.ArrayListUnmanaged(u8) = .empty;
defer reply_buf.deinit(gpa);
// §3.7 L574-579 — drive rounds until the client's state machine terminates;
// the worked exchange converges in two rounds, so a small bound suffices.
var round: usize = 0;
while (client.state == .exchange and round < 8) : (round += 1) {
const request = try client.buildRequest(gpa, cs.store(), &req_buf);
const reply = try server.respond(gpa, ss.store(), request, &reply_buf);
_ = try client.consumeReply(gpa, cs.store(), reply);
}
try std.testing.expectEqual(ClientState.terminated, client.state);
// §3.7 L574-579 — both endpoints now hold X ∪ Y, and neither retains a
// content-null rid whose hash the peer holds (no unresolved phantom).
const union_hashes = [_][]const u8{ h2_ancestor, h2_manifest_x, h2_alpha, h2_manifest_y, h2_beta };
for (union_hashes) |h| {
const crid = cs.store().lookup(h) orelse return error.TestUnexpectedResult;
try std.testing.expect(cs.store().content(crid) != null);
const srid = ss.store().lookup(h) orelse return error.TestUnexpectedResult;
try std.testing.expect(ss.store().content(srid) != null);
}
try expectNoPhantom(cs.store());
try expectNoPhantom(ss.store());
}
// §3.3 r24 — a converged endpoint retains no phantom (content-null) rid.
fn expectNoPhantom(store: repostate.ArtifactStore) !void {
const n = store.count();
var rid: repostate.Rid = 1;
while (rid <= n) : (rid += 1) {
try std.testing.expect(store.content(rid) != null);
}
}
// ---------------------------------------------------------------------------
// §8 server-side clone — Server.respond behaviour (public surface). Expected
// values come from the sync RFC §8 text only, never from observing Fossil.
// ---------------------------------------------------------------------------
/// Walk a reply's cards, framing past a file/cfile payload (binary, LF-legal) so
/// no payload byte is miscounted as a card boundary. `payload` holds the last
/// content card's CSIZE/SIZE bytes. Built on `sync`'s public parsing API only.
const CloneReplyWalk = struct {
body: []const u8,
pos: usize = 0,
payload: []const u8 = "",
fn next(self: *CloneReplyWalk) ?sync.CardClass {
while (self.pos < self.body.len) {
const line = sync.readLine(self.body, self.pos) catch return null;
self.pos = line.next;
if (sync.isCommentLine(line.bytes)) continue;
const cc = sync.classifyCard(line.bytes);
if (cc.operator) |op| switch (op) {
.cfile => {
const c = sync.parseCfileCard(cc.tokens) catch return cc;
const p = sync.consumeContentPayload(self.body, self.pos, c.compressed_size) catch return cc;
self.payload = p.bytes;
self.pos = sync.cfilePaddingEnd(self.body, p); // §5.1 — skip the padding LF like the receiver
},
.file => {
const c = sync.parseFileCard(cc.tokens) catch return cc;
const p = sync.consumeContentPayload(self.body, self.pos, c.size) catch return cc;
self.payload = p.bytes;
self.pos = p.next;
},
else => {},
};
return cc;
}
return null;
}
/// draft-fossil-submanifests-00 §7.2 — consume the leading
/// `pragma avoid-submanifests` card every reply from a server whose §8.1
/// setting is false carries, asserting it is there.
///
/// A consuming ASSERTION rather than a silent skip: the card is now part of
/// the sequence these tests walk, so each one should say so once and then
/// go on describing the §8.2 clone sequence it exists to pin. Hiding the
/// card inside `next` would make a regression that stopped emitting it
/// invisible to every test here.
fn expectAvoidSubmanifests(self: *CloneReplyWalk) !void {
const cc = self.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(sync.Operator.pragma, cc.operator.?);
try std.testing.expectEqual(sync.PragmaName.@"avoid-submanifests", sync.parsePragmaName(cc.tokens.items[1]).?);
}
};
fn cloneTestServer() sync.Server {
return .{
.version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
.self_servercode = a1_servercode,
.project_code = a1_projectcode,
};
}
test "sync §8.2: respond answers clone 3 1 with RID-ordered cfiles, then clone_seqno 0 and push" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// Dense RIDs 1,2 in insertion order (repostate contract).
_ = try ms.store().put(a1_manifest_hash, "manifest content\n");
_ = try ms.store().put(a1_send_hash, "alpha content from client\n");
const server = cloneTestServer();
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\nclone 3 1\n", &out);
var walk = CloneReplyWalk{ .body = reply };
try walk.expectAvoidSubmanifests(); // submanifests §7.2 — leads the reply
// §8.2 L1517-1518 — cfile cards in RID order; each decodes to stored content.
const c1 = walk.next().?;
try std.testing.expectEqual(sync.Operator.cfile, c1.operator.?);
try std.testing.expectEqualStrings(a1_manifest_hash, (try sync.parseCfileCard(c1.tokens)).hash);
const d1 = try codec.decode(gpa, walk.payload, sync.reply_body_cap);
defer gpa.free(d1);
try std.testing.expectEqualStrings("manifest content\n", d1);
const c2 = walk.next().?;
try std.testing.expectEqual(sync.Operator.cfile, c2.operator.?);
try std.testing.expectEqualStrings(a1_send_hash, (try sync.parseCfileCard(c2.tokens)).hash);
const d2 = try codec.decode(gpa, walk.payload, sync.reply_body_cap);
defer gpa.free(d2);
try std.testing.expectEqualStrings("alpha content from client\n", d2);
// §8.2 L1519-1520 — clone_seqno 0 (exhausted), then push SERVERCODE PROJECTCODE.
const c3 = walk.next().?;
try std.testing.expectEqual(sync.Operator.clone_seqno, c3.operator.?);
try std.testing.expectEqual(@as(i64, 0), try sync.parseCloneSeqno(c3.tokens));
const c4 = walk.next().?;
try std.testing.expectEqual(sync.Operator.push, c4.operator.?);
const pc = sync.parsePushPull(c4.tokens).?;
try std.testing.expectEqualStrings(a1_servercode, pc.servercode);
try std.testing.expectEqualStrings(a1_projectcode, pc.projectcode);
// §8 — clone is receive-only: no pull/push igot/gimme walk follows (the card
// sequence is exactly cfile,cfile,clone_seqno,push and then exhausted).
try std.testing.expect(walk.next() == null);
}
test "sync §8.2 L1520: server-version precedes the clone sequence iff the client-version is dated" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(a1_manifest_hash, "m\n");
const server = cloneTestServer();
// Dated client-version solicits server-version (§10.1 r11); §8.2 puts it first.
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const dated = try server.respond(gpa, ms.store(), "pragma client-version 22700 20260101 120000\nclone 3 1\n", &out);
try std.testing.expect(std.mem.startsWith(u8, dated, "pragma server-version " ++
std.fmt.comptimePrint("{d}", .{sync.submanifest_min_peer_version}) ++ " "));
// Undated client-version suppresses it (§10.1 r12): the reply then opens on
// the submanifests §7.2 pragma, which sits in the same leading pragma run,
// and the clone sequence follows it.
var out2: std.ArrayListUnmanaged(u8) = .empty;
defer out2.deinit(gpa);
const undated = try server.respond(gpa, ms.store(), "pragma client-version 22700\nclone 3 1\n", &out2);
try std.testing.expect(std.mem.indexOf(u8, undated, "pragma server-version") == null);
try std.testing.expect(std.mem.startsWith(u8, undated, "pragma avoid-submanifests\ncfile "));
}
test "sync §8.2 L1504-1505: clone skips a content-absent RID (phantom), sending only held artifacts" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(a1_manifest_hash, "held-1\n"); // rid 1: held
_ = try ms.store().intern(a1_send_hash); // rid 2: phantom (content absent)
_ = try ms.store().put(a1_held_hash, "held-3\n"); // rid 3: held
const server = cloneTestServer();
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\nclone 3 1\n", &out);
var walk = CloneReplyWalk{ .body = reply };
try walk.expectAvoidSubmanifests(); // submanifests §7.2 — leads the reply
// Two cfiles for the held rids 1 and 3 — the phantom rid 2 is silently
// skipped (§8.2 L1505 "skipping gaps") — then clone_seqno 0.
try std.testing.expectEqualStrings(a1_manifest_hash, (try sync.parseCfileCard(walk.next().?.tokens)).hash);
try std.testing.expectEqualStrings(a1_held_hash, (try sync.parseCfileCard(walk.next().?.tokens)).hash);
const cs = walk.next().?;
try std.testing.expectEqual(sync.Operator.clone_seqno, cs.operator.?);
try std.testing.expectEqual(@as(i64, 0), try sync.parseCloneSeqno(cs.tokens));
try std.testing.expectEqual(sync.Operator.push, walk.next().?.operator.?);
try std.testing.expect(walk.next() == null);
}
test "sync §8.1: clone 3 0 is the fatal invalid-sequence error, resetting output (no cfile, no clone_seqno)" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(a1_manifest_hash, "m\n");
const server = cloneTestServer();
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// §8.1 L1493-1498 — VERSION>=2, digit-only SEQNO 0: the accumulated output is
// cleared and the response is exactly the fossilized error card.
const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\nclone 3 0\n", &out);
try std.testing.expectEqualStrings("error invalid\\sclone\\ssequence\\snumber\n", reply);
}
test "sync §3.8/§8.9: a clone response is a pure function of (store, request) — identical bytes on replay" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(a1_manifest_hash, "manifest\n");
_ = try ms.store().put(a1_send_hash, "alpha\n");
const server = cloneTestServer();
const req = "pragma client-version 22700\nclone 3 1\n";
var out_a: std.ArrayListUnmanaged(u8) = .empty;
defer out_a.deinit(gpa);
var out_b: std.ArrayListUnmanaged(u8) = .empty;
defer out_b.deinit(gpa);
const a = try server.respond(gpa, ms.store(), req, &out_a);
const b = try server.respond(gpa, ms.store(), req, &out_b);
// §3.8 stateless server: the server wrote nothing across requests, so a byte-
// identical (store, request) yields a byte-identical reply.
try std.testing.expectEqualSlices(u8, a, b);
}
// §3.3 r24 / §8.5 — a cluster is the bulk form of igot: its M cards announce
// artifacts the peer holds. A fossil server folds everything older than its
// ~100-entry unclustered window into cluster artifacts and stops announcing
// those members individually, so a client that does not phantomize a received
// cluster's members has nothing left to gimme and §3.7(a) terminates the
// exchange after one round — a pull that silently fetches nothing.
const cluster_member_a = "0d0c8b4c67c02b90a6f0e0e6b4d4d0b7f96e9de0a0da1a2c2a5f1cba55a9f3f4";
const cluster_member_b = "9a3b0f1e5f6d7c8b9a0b1c2d3e4f50617283940a1b2c3d4e5f60718293a4b5c6";
const cluster_hash = "20376927fe72a07a8cd5e0f02530d85493742fba659ae26571825dad7f90217f";
const cluster_body = "M " ++ cluster_member_a ++ "\nM " ++ cluster_member_b ++ "\nZ 8a09b1c8ae2c5f4a2d5e9f0b7c1d3e4f\n";
test "sync §3.3 r24/§8.5: a pulled cluster phantomizes its members and the next round gimmes them" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
const reply = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ cluster_hash, cluster_body.len, cluster_body });
defer gpa.free(reply);
const obs = try client.consumeReply(gpa, ms.store(), reply);
// The members are unheld, so each M card must mint a phantom — the same
// disposition an `igot` for that hash would produce (§3.3 r25).
try std.testing.expect(ms.store().lookup(cluster_member_a) != null);
try std.testing.expect(ms.store().lookup(cluster_member_b) != null);
try std.testing.expectEqual(@as(usize, 2), client.phantoms.count());
try std.testing.expect(obs.phantom_created);
try std.testing.expect(obs.phantom_table_nonempty);
// §3.7(a) — with a non-empty phantom table after versioned progress the
// exchange must continue; terminating here is the silent no-op pull.
try std.testing.expect(continueExchange(obs));
try std.testing.expectEqual(ClientState.exchange, client.state);
// §3.2 L397-399 — and the follow-up request must actually ask for them.
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const req = try client.buildRequest(gpa, ms.store(), &out);
try expectHasLine(req, "gimme " ++ cluster_member_a);
try expectHasLine(req, "gimme " ++ cluster_member_b);
}
test "sync §3.3 r24: a terminated pull that still holds phantoms reports them unresolved" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
// A peer announcement this side never resolved: the phantom outlives the
// round, and the count is what lets a caller refuse to report success.
_ = try client.consumeReply(gpa, ms.store(), "igot " ++ a1_send_hash ++ "\n");
try std.testing.expectEqual(@as(usize, 1), client.unresolvedPhantoms(ms.store()));
// Once the content lands the same phantom is resolved, so the count is a
// live "what the peer announced and this side still lacks", not a tally of
// every phantom ever minted.
_ = try ms.store().put(a1_send_hash, "alpha\n");
try std.testing.expectEqual(@as(usize, 0), client.unresolvedPhantoms(ms.store()));
}
// ---------------------------------------------------------------------------
// Section 3.3 L527-530 — the phantom-scan shun filter. "The ordinary phantom
// scan excludes peer-lacks hashes, names in the repository shun table, and
// private hashes unless private sync is enabled; shun-sha1 does not itself
// suppress an otherwise eligible gimme." The filter sits at the SCAN, not at
// phantom CREATION (§3.3 r24(1) L511-514 — "An igot HASH creates a phantom only
// when HASH is absent and the receiver can obtain it" — is unconditional on the
// shun table), so a shunned announcement still mints an inert phantom that is
// never requested. §8.8 L1777-1779 restates the same rule imperatively, and
// L516-521 makes the filter inputs a per-round question.
//
// ERRATUM CANDIDATE (sync §3.3 L527-530): the draft keys the table by "complete
// artifact name" (§3.3 L518-519; artifact-format §3.2 L347-350 fixes the two
// complete forms) but is silent on what an ABBREVIATED wire name means at the
// scan. The reading implemented here is exact-key-miss: a non-complete name
// cannot be a key, so `false` is the exact answer, never a prefix match. The
// case is unreachable in practice — `parseIgotCard`/`parseGimmeCard` reject any
// other width as malformed, so no abbreviated name ever becomes a phantom — and
// the erratum only fixes what the predicate answers if one ever did.
// ---------------------------------------------------------------------------
/// A test shun table over a fixed list of complete wire names, plus a round
/// counter so a test can make the answer change between rounds. Exact string
/// equality only — never a prefix compare.
const FixtureShunSet = struct {
names: []const []const u8,
/// When true, `contains` answers false until `armed` is set — the instrument
/// for "the scan re-probes the set every round".
gated: bool = false,
armed: bool = false,
probes: usize = 0,
fn shunSet(self: *FixtureShunSet) sync.ShunSet {
return .{ .context = self, .vtable = &vtable };
}
const vtable: sync.ShunSet.VTable = .{ .contains = containsName };
fn containsName(context: *anyopaque, name: []const u8) bool {
const self: *FixtureShunSet = @ptrCast(@alignCast(context));
self.probes += 1;
if (self.gated and !self.armed) return false;
for (self.names) |n| {
if (std.mem.eql(u8, n, name)) return true;
}
return false;
}
};
/// Does `request` carry `gimme HASH` as a whole card line?
fn hasGimmeCard(request: []const u8, hash: []const u8) bool {
var it = std.mem.splitScalar(u8, request, '\n');
while (it.next()) |line| {
if (line.len == 6 + hash.len and
std.mem.startsWith(u8, line, "gimme ") and
std.mem.eql(u8, line[6..], hash)) return true;
}
return false;
}
test "sync §3.3 L527-530: ShunSet.none is the identity — every eligible phantom is still gimme'd" {
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);
// A default-constructed Client carries `.none`: the regression lock that
// every pre-existing sync test keeps its behavior verbatim.
try std.testing.expect(!client.shun.contains(a1_phantom_1));
_ = try client.consumeReply(gpa, ms.store(), a1_round1_reply);
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(gpa);
const request = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(hasGimmeCard(request, a1_phantom_1));
try std.testing.expect(hasGimmeCard(request, a1_phantom_2));
}
test "sync §3.3 L527-530: a phantom whose name is in the shun table is never gimme'd, in any round" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{a1_phantom_1} };
var client = Client{ .operation = .sync, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), a1_round1_reply);
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(gpa);
// Across every round the phantom survives: the shunned name is suppressed
// and the unshunned sibling is not. Nothing about the filter is one-shot.
var round: usize = 0;
while (round < 3) : (round += 1) {
const request = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(!hasGimmeCard(request, a1_phantom_1));
try std.testing.expect(hasGimmeCard(request, a1_phantom_2));
}
}
test "sync §3.3 r24(1) L511-514 vs L527-530: an igot for a shunned hash still CREATES the phantom" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{ a1_phantom_1, a1_phantom_2 } };
var client = Client{ .operation = .sync, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), a1_round1_reply);
// Creation is untouched: the round-1 igot cards mint both phantoms even
// though both names are shunned. The phantom is inert and permanent — the
// draft filters the SCAN, and inventing a creation-side rule it does not
// have would also lose the §3.7(a) continuation signal.
try std.testing.expect(obs.phantom_created);
try std.testing.expectEqual(@as(usize, 2), client.phantoms.count());
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));
// …and it is never requested.
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(gpa);
const request = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(!hasGimmeCard(request, a1_phantom_1));
try std.testing.expect(!hasGimmeCard(request, a1_phantom_2));
}
test "sync §3.3 L527-530: the scan re-probes the shun set every round, so a shun applied between rounds is honoured by the next" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{a1_phantom_1}, .gated = true };
var client = Client{ .operation = .sync, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), a1_round1_reply);
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(gpa);
// Round 1: the set is not yet armed, so the phantom is requested.
const round1 = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(hasGimmeCard(round1, a1_phantom_1));
const probes_after_round1 = shun.probes;
try std.testing.expect(probes_after_round1 > 0);
// The operator shuns it BETWEEN rounds.
shun.armed = true;
// Round 2 honours it: `buildRequest` asks the set again rather than reusing
// a cached answer from construction time.
const round2 = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(!hasGimmeCard(round2, a1_phantom_1));
try std.testing.expect(shun.probes > probes_after_round1);
}
test "sync §3.3 L527-530: the scan asks with the complete wire name — a prefix is a different question" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// The table holds a 12-hex PREFIX of the phantom's name. Exact-key lookup
// means that is not this phantom's key, so the gimme still goes out: the
// protocol layer never truncates, pads, or prefix-matches the name it asks
// about (see the erratum candidate above).
var shun = FixtureShunSet{ .names = &.{a1_phantom_1[0..12]} };
var client = Client{ .operation = .sync, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), a1_round1_reply);
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(gpa);
const request = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(hasGimmeCard(request, a1_phantom_1));
}
test "sync §3.3 L527-530: an unresolved-phantom tally excludes a phantom the shun table suppressed" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{a1_phantom_1} };
var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), a1_round1_reply);
// Both phantoms exist and neither is held, but only the UNSHUNNED one is an
// unmet expectation: the shunned name was never requested, by the operator's
// own instruction. Counting it would make `zit pull` report "the peer holds N
// artifacts this pull did not obtain" on every pull forever — the peer
// re-announces the hash each round — for obeying the shun row.
try std.testing.expectEqual(@as(usize, 2), client.phantoms.count());
try std.testing.expectEqual(@as(usize, 1), client.unresolvedPhantoms(ms.store()));
// With no shun table both count: the exclusion is the shun filter's, not a
// weakening of the tally.
var bare = Client{ .operation = .pull, .state = .exchange };
defer bare.deinit(gpa);
var bare_ms = repostate.MemStore.init(gpa);
defer bare_ms.deinit();
_ = try bare.consumeReply(gpa, bare_ms.store(), a1_round1_reply);
try std.testing.expectEqual(@as(usize, 2), bare.unresolvedPhantoms(bare_ms.store()));
}
test "sync §3.3 L527-530 / §8.8 L1777-1779: a server reply never gimme's a shunned phantom it interned from a push igot" {
const gpa = std.testing.allocator;
// §3.3 r24(1) L511-514 — a server under push-receive is a receiver, so the
// client's igot for content it lacks interns a phantom the reply would
// otherwise beg for. Left unfiltered, the shunning side ACQUIRES the shunned
// artifact through its own serve direction: the peer answers the gimme with a
// file card and `respond` stores it.
const request = "push " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\nigot " ++ a1_phantom_1 ++ "\n";
// Unshunned: the reply asks for it (the behavior the filter must change).
{
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(hasGimmeCard(reply, a1_phantom_1));
}
// Shunned: the phantom is still interned (§3.3 r24(1) is unconditional on the
// shun table) and the reply is silent about it.
{
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
var shun = FixtureShunSet{ .names = &.{a1_phantom_1} };
const server = Server{ .version = h2_server_version, .shun = shun.shunSet() };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(!hasGimmeCard(reply, a1_phantom_1));
const rid = ss.store().lookup(a1_phantom_1) orelse return error.TestUnexpectedResult;
try std.testing.expect(!ss.store().held(rid)); // the phantom exists, unheld
}
}
test "sync §9.2 / artifact-format §3.2 L347-350: an igot naming a non-complete hash is malformed, so no abbreviated name can become a phantom" {
// The fail-CLOSED half of the shun filter's key domain. §3.3 L518-519 keys
// the shun table by complete artifact name; if an abbreviated igot could mint
// a phantom, that phantom's name could not be a key, the filter would answer
// "not shunned", and the scan would request it — the fail-open case
// `logstore.LogStore.isShunned`'s caller contract forbids by name. The repair
// is at the parse layer, where `gimme` already lives: "A candidate name of
// any other length MUST be rejected as invalid" (artifact-format §3.2 L349-350).
const short = a1_phantom_1[0..60]; // even length, decodes, still not a form
inline for (.{ a1_phantom_1[0..12], a1_phantom_1[0..39], a1_phantom_1[0..41], a1_phantom_1[0..63] }) |bad| {
try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard("igot " ++ bad)));
}
try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard("igot " ++ short ++ " 1")));
// Both complete forms still parse.
_ = try parseIgotCard(tokenizeCard("igot " ++ a1_phantom_1)); // 64-hex
_ = try parseIgotCard(tokenizeCard("igot " ++ a1_phantom_1[0..40])); // 40-hex
// …and the client honours it end to end: a reply whose igot carries an
// abbreviated name mints no phantom, so the scan has nothing out of domain to
// ask the shun set about.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange };
defer client.deinit(gpa);
const reply = "igot " ++ a1_phantom_1[0..60] ++ "\n";
try std.testing.expectError(error.FatalProtocolError, client.consumeReply(gpa, ms.store(), reply));
try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
}
// ---------------------------------------------------------------------------
// Section 7.7 L1434-1463 — receive-side target admission. "Admission applies to
// the received target HASH, not independently to DELTASRC. A target is shunned
// for admission when its complete name is in the repository shun table or when
// shun-sha1 applies to its SHA1 name. A non-clone file whose target is shunned
// is consumed and discarded after its payload and pending private marker; it
// emits no error, does not set content-received, and makes no repository
// mutation. A clone file bypasses this combined target check. For cfile, the
// private-admission behavior of Section 7.5 runs first; if processing
// continues, a shunned target is consumed and discarded after its payload even
// in clone, and its raw parser artifact-card count nevertheless increments
// after handler invocation."
//
// The asymmetry between `file` in clone and `cfile` in clone is DELIBERATE and
// is what L1440-1442 says; it is the pair of tests below that pins it.
// ---------------------------------------------------------------------------
/// A 64-hex (SHA3) target name the fixture shun table names, and two siblings.
const s77_target = "d" ** 64;
const s77_other = "e" ** 64;
/// 22 bytes — the `file` SIZE the tests below frame with.
const s77_payload = "shunned artifact bytes";
test "sync §7.7 L1437-1440: a non-clone file card whose target is shunned is consumed and discarded, mutating nothing" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{s77_target} };
var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
// The trailing igot is the instrument for "consumed EXACTLY SIZE bytes":
// it is only reached if the cursor resumed at the byte after the payload.
const body = "file " ++ s77_target ++ " 22\n" ++ s77_payload ++ "igot " ++ s77_other ++ "\n";
const obs = try client.consumeReply(gpa, ms.store(), body);
// "makes no repository mutation" — not even the intern a `put` would do.
try std.testing.expect(ms.store().lookup(s77_target) == null);
// "does not set content-received".
try std.testing.expect(!obs.versioned_content_delivered);
try std.testing.expect(!obs.artifact_received_this_round);
// "it emits no error" — at this receiver an error is `abort()`, i.e. the
// FatalProtocolError the `try` above would have surfaced. Processing
// continued instead, and the next card landed.
try std.testing.expect(ms.store().lookup(s77_other) != null);
}
test "sync §7.7 L1440: a CLONE file card bypasses the combined target check — the shunned target is admitted and stored" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var shun = FixtureShunSet{ .names = &.{s77_target} };
var client = Client{ .operation = .clone, .state = .clone_round0, .shun = shun.shunSet() };
defer client.deinit(gpa);
const body = "clone_seqno 0\nfile " ++ s77_target ++ " 22\n" ++ s77_payload;
const obs = try client.consumeReply(gpa, ms.store(), body);
const rid = ms.store().lookup(s77_target) orelse return error.TestUnexpectedResult;
try std.testing.expect(ms.store().held(rid));
try std.testing.expectEqualStrings(s77_payload, ms.store().content(rid).?);
try std.testing.expect(obs.versioned_content_delivered);
}
test "sync §7.7 L1440-1444: a cfile whose target is shunned is discarded EVEN IN CLONE, and the artifact-card count still increments" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const plain = "the compressed-form artifact this repository has shunned";
const container = try zlibContainerEncode(gpa, plain, .default);
defer gpa.free(container);
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
try body.appendSlice(gpa, "clone_seqno 0\n");
const head = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ s77_target, plain.len, container.len });
defer gpa.free(head);
try body.appendSlice(gpa, head);
try body.appendSlice(gpa, container);
try body.append(gpa, '\n'); // §5.1 optional trailing-LF padding
var shun = FixtureShunSet{ .names = &.{s77_target} };
var client = Client{ .operation = .clone, .state = .clone_round0, .shun = shun.shunSet() };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body.items);
try std.testing.expect(ms.store().lookup(s77_target) == null);
try std.testing.expect(!obs.versioned_content_delivered);
try std.testing.expect(!obs.artifact_received_this_round);
// L1443-1444 — "its raw parser artifact-card count nevertheless increments
// after handler invocation": the §3.7(e) clone-continuation counter is a RAW
// handler count, so a discarded cfile still advances the clone.
try std.testing.expect(obs.artifact_count_grew);
}
test "sync §7.7 L1440-1442: for cfile the §7.5 private-admission behavior runs FIRST" {
// The two arms would both discard, so the discriminator is WHICH ONE did:
// the §7.5 arm records `private_content_discarded`, the §7.7 arm records
// nothing. Run the shun check first and that flag goes false.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const plain = "a private artifact whose name is also in the shun table";
const container = try zlibContainerEncode(gpa, plain, .default);
defer gpa.free(container);
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
try body.appendSlice(gpa, "private\n");
const head = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ s77_target, plain.len, container.len });
defer gpa.free(head);
try body.appendSlice(gpa, head);
try body.appendSlice(gpa, container);
try body.append(gpa, '\n');
var shun = FixtureShunSet{ .names = &.{s77_target} };
// private_granted false — §7.5 L1362-1367 discards for lack of the grant.
// (The `L2383-2385` spelling on the sibling lines in this file indexes an
// older draft rendering and is part of the deferred citation sweep, which
// covers `sync_test.zig` as well as `sync.zig`.)
var client = Client{ .operation = .sync, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body.items);
try std.testing.expect(obs.private_content_discarded);
try std.testing.expect(ms.store().lookup(s77_target) == null);
}
test "sync §7.7 L1444-1450: an admitted target whose DELTASRC is shunned reconstructs from a held basis" {
// "the receiver does not apply admission independently to that source:
// content still held locally MAY serve as the delta basis". zit lands this
// with NO carve-out code: admission is simply never asked about DELTASRC.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const basis_plain = "the basis artifact bytes, long enough that a forward delta over them is compact";
const target_plain = basis_plain ++ " plus a small appended mutation";
var nb: [64]u8 = undefined;
const basis_name = artifact.artifactName(.sha3_256, basis_plain, &nb);
var nt: [64]u8 = undefined;
const target_name = artifact.artifactName(.sha3_256, target_plain, &nt);
_ = try ms.store().put(basis_name, basis_plain);
const raw = try delta.create(gpa, basis_plain, target_plain);
defer gpa.free(raw);
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
const head = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n", .{ target_name, basis_name, raw.len });
defer gpa.free(head);
try body.appendSlice(gpa, head);
try body.appendSlice(gpa, raw);
var shun = FixtureShunSet{ .names = &.{basis_name} };
var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
_ = try client.consumeReply(gpa, ms.store(), body.items);
const rid = ms.store().lookup(target_name) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(target_plain, ms.store().content(rid).?);
}
test "sync §7.7 L1447-1450: with the shunned DELTASRC unavailable, ordinary dangling-delta handling retains the source phantom and §3.3 never gimme's it" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const basis_plain = "the basis artifact bytes, long enough that a forward delta over them is compact";
const target_plain = basis_plain ++ " plus a small appended mutation";
var nb: [64]u8 = undefined;
const basis_name = artifact.artifactName(.sha3_256, basis_plain, &nb);
var nt: [64]u8 = undefined;
const target_name = artifact.artifactName(.sha3_256, target_plain, &nt);
const raw = try delta.create(gpa, basis_plain, target_plain);
defer gpa.free(raw);
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
const head = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n", .{ target_name, basis_name, raw.len });
defer gpa.free(head);
try body.appendSlice(gpa, head);
try body.appendSlice(gpa, raw);
var shun = FixtureShunSet{ .names = &.{basis_name} };
var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
const obs = try client.consumeReply(gpa, ms.store(), body.items);
// "ordinary dangling-delta handling creates or retains the source phantom"
try std.testing.expect(obs.phantom_created);
const src_rid = ms.store().lookup(basis_name) orelse return error.TestUnexpectedResult;
try std.testing.expect(!ms.store().held(src_rid));
// "but the Section 3.3 scan excludes it, so it is not requested."
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(gpa);
const request = try client.buildRequest(gpa, ms.store(), &buf);
try std.testing.expect(!hasGimmeCard(request, basis_name));
}
test "sync §7.7 L1434-1437: shun-sha1 is the OTHER half of one combined predicate, not a second check" {
// "A target is shunned for admission when its complete name is in the
// repository shun table OR when shun-sha1 applies to its SHA1 name." An
// empty shun table plus shun-sha1 still discards a 40-hex target.
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var client = Client{ .operation = .pull, .state = .exchange, .hash_policy = .shun_sha1 };
defer client.deinit(gpa);
const body = "file " ++ test_hash_a ++ " 22\n" ++ s77_payload ++ "igot " ++ s77_other ++ "\n";
const obs = try client.consumeReply(gpa, ms.store(), body);
try std.testing.expect(ms.store().lookup(test_hash_a) == null);
try std.testing.expect(!obs.versioned_content_delivered);
try std.testing.expect(ms.store().lookup(s77_other) != null);
// A 64-hex target under the same policy is admitted: the SHA1 half of the
// predicate is keyed on the NAME's algorithm, never on the card.
var ms2 = repostate.MemStore.init(gpa);
defer ms2.deinit();
var c2 = Client{ .operation = .pull, .state = .exchange, .hash_policy = .shun_sha1 };
defer c2.deinit(gpa);
_ = try c2.consumeReply(gpa, ms2.store(), "file " ++ s77_target ++ " 22\n" ++ s77_payload);
try std.testing.expect(ms2.store().lookup(s77_target) != null);
}
test "sync §7.7 L1437-1440: a server push-receive discards a shunned pushed file — no store mutation and no error card" {
const gpa = std.testing.allocator;
const request = "push " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\n" ++
"file " ++ s77_target ++ " 22\n" ++ s77_payload;
// Unshunned: the server stores it (the behavior the admission check changes).
{
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
const server = Server{ .version = h2_server_version };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
_ = try server.respond(gpa, ss.store(), request, &out);
const rid = ss.store().lookup(s77_target) orelse return error.TestUnexpectedResult;
try std.testing.expect(ss.store().held(rid));
}
// Shunned: server push-receive is always non-clone, so the discard arm runs.
{
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
var shun = FixtureShunSet{ .names = &.{s77_target} };
const server = Server{ .version = h2_server_version, .shun = shun.shunSet() };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(ss.store().lookup(s77_target) == null);
// "it emits no error" — literally, over the wire this time.
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
}
}
test "sync §7.7 L1437-1440: a server push-receive of a shunned target in DELTA form emits no error either — the gate is above the reconstruction, not on the store" {
// The full form never reaches a fault, so gating only the pass-2 `put` looks
// correct until the target arrives as `file HASH DELTASRC SIZE`. Then the
// scan reconstructs FIRST, and a basis this repository does not hold turns a
// discard §7.7 requires to be silent into `error malformed file line`. The
// absent basis is the COMMON case for a shunned target. L1437-1440 —
// "consumed and discarded after its payload ...; it emits no error" — draws
// no distinction between the two forms of the card.
const gpa = std.testing.allocator;
// Deliberately NOT a well-formed delta and against a basis nothing holds:
// both would fault today, and neither may be observable through the reply.
const request = "push " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\n" ++
"file " ++ s77_target ++ " " ++ s77_other ++ " 4\n" ++ "junk";
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
var shun = FixtureShunSet{ .names = &.{s77_target} };
const server = Server{ .version = h2_server_version, .shun = shun.shunSet() };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
// "makes no repository mutation" — not even the DELTASRC phantom: admission
// is decided before DELTASRC is looked at (L1434-1435 is a TARGET question).
try std.testing.expect(ss.store().lookup(s77_target) == null);
try std.testing.expect(ss.store().lookup(s77_other) == null);
try std.testing.expectEqual(@as(repostate.Rid, 0), ss.store().count());
}
test "sync §7.7 L1444-1450: on the SERVER, an admitted target whose shunned DELTASRC is unresolvable is dangling-handled — source phantom kept, never gimme'd, no error" {
// "For an admitted target whose DELTASRC name is in the repository shun
// table, the receiver does not apply admission independently to that source:
// content still held locally MAY serve as the delta basis; if the content is
// unavailable, ordinary dangling-delta handling creates or retains the source
// phantom, but the Section 3.3 scan excludes it, so it is not requested."
//
// The basis is unavailable, so the second branch is the one that runs. (In
// the CLI's served store a shunned name is additionally masked out of the
// by-name index, which is how this arrives in production; the fixture reaches
// the same branch by simply not holding it.) Two things must hold together:
// no error card (the whole round would otherwise be replaced by one), and the
// phantom exists yet is excluded from the reply's §3.3 L527-530 gimme scan.
const gpa = std.testing.allocator;
const basis = "a shunned basis the served store will not resolve by name";
const target = "a shunned basis the served store will not resolve by name, plus a tail";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
var shun = FixtureShunSet{ .names = &.{basis_hash} };
const server = Server{ .version = h2_server_version, .shun = shun.shunSet() };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d });
defer gpa.free(request);
const reply = try server.respond(gpa, ss.store(), request, &out);
// Not an error round.
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
// The target is ADMITTED but unresolvable, so it does not land.
try std.testing.expect(ss.store().lookup(target_hash) == null);
// The SOURCE PHANTOM was created even though the source is shunned —
// admission is not applied independently to DELTASRC.
const src_rid = ss.store().lookup(basis_hash) orelse return error.TestUnexpectedResult;
try std.testing.expect(!ss.store().held(src_rid));
// §3.3 L527-530 — but the reply never begs for it.
try std.testing.expect(std.mem.indexOf(u8, reply, basis_hash) == null);
}
test "sync §7.7 L1444-1450: an absent delta source becomes a phantom either way; only a shunned one goes unrequested" {
// §7.4 erratum — every absent DELTASRC takes Fossil's dangling-delta path
// and is left as a phantom. §7.7 L1447-1450 adds one thing for a source
// in the shun table: "the Section 3.3 scan excludes it, so it is not
// requested". The two runs differ in exactly the shun table.
const gpa = std.testing.allocator;
const basis = "the basis neither run holds, so both take the BasisAbsent arm";
const target = "the basis neither run holds, so both take the BasisAbsent arm, plus tail";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d });
defer gpa.free(request);
for ([_]bool{ false, true }) |shunned| {
var ss = repostate.MemStore.init(gpa);
defer ss.deinit();
var shun = FixtureShunSet{ .names = &.{basis_hash} };
const server = Server{ .version = h2_server_version, .shun = if (shunned) shun.shunSet() else .none };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = try server.respond(gpa, ss.store(), request, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
const src_rid = ss.store().lookup(basis_hash) orelse return error.TestUnexpectedResult;
try std.testing.expect(!ss.store().held(src_rid));
try std.testing.expectEqual(!shunned, std.mem.indexOf(u8, reply, basis_hash) != null);
}
}
test "sync §7.7 L1437-1440 / §3.3 L518-525: a shun applied BETWEEN rounds stops a deferred delta's target from landing on completion" {
// "A non-clone file whose target is shunned ... makes no repository
// mutation." The card arms alone cannot carry that clause for a DELTA: a
// dangling delta is retained on one round and its target is STORED on a later
// one, out of the completion path, where no card arm runs. §3.3 L518-525
// makes the shun table durable and locally mutable, and the scan re-probes it
// every round, so the completion must re-ask too.
const gpa = std.testing.allocator;
const basis = "the deferred delta's basis, delivered on the round after the shun";
const target = "the deferred delta's basis, delivered on the round after the shun, plus tail";
const d = try delta.create(gpa, basis, target);
defer gpa.free(d);
var tbuf: [64]u8 = undefined;
const target_hash = artifact.artifactName(.sha3_256, target, &tbuf);
var bbuf: [64]u8 = undefined;
const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf);
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// Gated: the target is not yet shunned when the delta is retained.
var shun = FixtureShunSet{ .names = &.{target_hash}, .gated = true };
var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() };
defer client.deinit(gpa);
// Round 1 — the delta arrives dangling and is retained (§7.4/§3.3).
const d_body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ target_hash, basis_hash, d.len, d });
defer gpa.free(d_body);
_ = try client.consumeReply(gpa, ms.store(), d_body);
try std.testing.expect(client.pending.hasTarget(target_hash)); // admitted at receipt
// The operator shuns the TARGET between rounds.
shun.armed = true;
// Round 2 — the basis arrives. It lands (it is not shunned), but the target
// it would complete does not: §7.7 makes no repository mutation for it.
const basis_body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ basis_hash, basis.len, basis });
defer gpa.free(basis_body);
_ = try client.consumeReply(gpa, ms.store(), basis_body);
try std.testing.expect(ms.store().content(ms.store().lookup(basis_hash).?) != null); // basis stored
// The target never becomes held, and the queue entry is gone (freed, not leaked).
if (ms.store().lookup(target_hash)) |rid| try std.testing.expect(!ms.store().held(rid));
try std.testing.expectEqual(@as(usize, 0), client.pending.itemCount());
try std.testing.expect(client.state != .aborted); // §7.7 emits no error
}
test "sync §7.7 L1437-1444: TargetAdmission.admits is the predicate PLUS the two L1440 card rules — a clone `file` bypasses, a clone `cfile` does not" {
// `admitInbound` deliberately carries no card type and no clone context
// (pinned above). `TargetAdmission` is where the draft's two rules attach, so
// a landing path far from any card arm — the deferred queue's completion —
// can re-ask the same question the arms asked. Both rules in one table:
// L1437-1440 non-clone `file` shunned -> discard
// L1440 clone `file` shunned -> BYPASS, admitted
// L1440-1444 either `cfile` shunned -> discard, even in clone
const shunned = "d" ** 64;
const clean = "e" ** 64;
var shun = FixtureShunSet{ .names = &.{shunned} };
const outside_clone = sync.TargetAdmission{ .shun = shun.shunSet(), .clone = false };
const in_clone = sync.TargetAdmission{ .shun = shun.shunSet(), .clone = true };
try std.testing.expect(!outside_clone.admits(shunned, false)); // non-clone file: discard
try std.testing.expect(in_clone.admits(shunned, false)); // clone file: BYPASS
try std.testing.expect(!outside_clone.admits(shunned, true)); // cfile: discard
try std.testing.expect(!in_clone.admits(shunned, true)); // cfile in clone: still discard
// An unshunned target is admitted in all four combinations — the rules gate
// only on a shunned name.
try std.testing.expect(outside_clone.admits(clean, false));
try std.testing.expect(in_clone.admits(clean, false));
try std.testing.expect(outside_clone.admits(clean, true));
try std.testing.expect(in_clone.admits(clean, true));
// §3.3 L523-525 — the identity: an empty table under `auto` admits everything,
// so every call site with no policy to express behaves as before admission
// existed.
try std.testing.expect(sync.TargetAdmission.unrestricted.admits(shunned, true));
}
test "sparse-cone §5.3: a progress-free error round-0 reply aborts the clone" {
// Goal: the §11.1 discard tolerance does not extend to a refusal -- a
// round-0 reply carrying an error card and NO clone progress (no
// clone-seqno card, no artifact) is fatal, and the peer's words are
// kept for the diagnostic. Method: feed the one-card refusal reply and
// pin the abort, the state, and the recorded text; contrast with §A.2's
// advancing reply (pinned by its own tests above).
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 refusal = "error sparse\\sorigin\\sdoes\\snot\\scover\\sthe\\srequested\\sclone\\scone\n";
try std.testing.expectError(
error.FatalProtocolError,
client.consumeReply(std.testing.allocator, ms.store(), refusal),
);
try std.testing.expectEqual(ClientState.aborted, client.state);
try std.testing.expectEqualStrings(
"sparse origin does not cover the requested clone cone",
client.peerErrorText().?,
);
}
test "sync §6.5: the server verifies an x-f-l-c cookie — accept, wrong signature refused, cookie+body login is duplicate" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const secret = sync.deriveSharedSecret("pc", "dan", "pw");
const server = sync.Server{
.version = .{ .version = 22900, .datetime = .{ .date = 20260715, .time = 120000 } },
.credential = .{ .user = "dan", .secret_hex = secret },
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const request = "pragma client-version 22700 20260101 000000\npull 0123456789abcdef 0123456789abcdef\n";
const nonce = sync.computeNonce(request);
const signature = sync.computeSignature(&nonce, &secret);
var cookie_buf: [256]u8 = undefined;
const cookie = sync.writeCookieLogin(.{ .user = "dan", .nonce = &nonce, .signature = &signature }, &cookie_buf).?;
// A valid per-request cookie authenticates: no error card in the reply.
{
const reply = try server.respondCookie(gpa, ms.store(), request, cookie, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null);
}
// A tampered signature is refused, not ignored.
{
var bad_sig = signature;
bad_sig[0] = if (bad_sig[0] == 'a') 'b' else 'a';
var bad_buf: [256]u8 = undefined;
const bad = sync.writeCookieLogin(.{ .user = "dan", .nonce = &nonce, .signature = &bad_sig }, &bad_buf).?;
const reply = try server.respondCookie(gpa, ms.store(), request, bad, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") != null);
}
// A nonce over different bytes (a replayed cookie) is refused: the
// credential binds to THIS request's body, which is the whole point.
{
const other = "pragma client-version 22700 20260101 000000\npush 0123456789abcdef 0123456789abcdef\n";
const reply = try server.respondCookie(gpa, ms.store(), other, cookie, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") != null);
}
// One login per request across BOTH channels (§6.1/§6.5): cookie plus a
// body login card is the duplicate_login protocol error.
{
const tail = "pull 0123456789abcdef 0123456789abcdef\n";
const body_nonce = sync.computeNonce(tail);
const body_sig = sync.computeSignature(&body_nonce, &secret);
var line: [256]u8 = undefined;
const login = try sync.writeLogin(.{ .user = "dan", .nonce = &body_nonce, .signature = &body_sig }, &line);
var both: std.ArrayListUnmanaged(u8) = .empty;
defer both.deinit(gpa);
try both.appendSlice(gpa, "pragma client-version 22700 20260101 000000\n");
try both.appendSlice(gpa, login);
try both.appendSlice(gpa, "\n");
try both.appendSlice(gpa, tail);
const dup_nonce = sync.computeNonce(both.items);
const dup_sig = sync.computeSignature(&dup_nonce, &secret);
var dup_buf: [256]u8 = undefined;
const dup_cookie = sync.writeCookieLogin(.{ .user = "dan", .nonce = &dup_nonce, .signature = &dup_sig }, &dup_buf).?;
const reply = try server.respondCookie(gpa, ms.store(), both.items, dup_cookie, &out);
try std.testing.expect(std.mem.indexOf(u8, reply, "error ") != null);
}
}
// ---------------------------------------------------------------------------
// Protected branches: the push-side membership rule.
// ---------------------------------------------------------------------------
/// A resolver over a fixed table of held check-ins; `trunk` is main.
const ProtectStub = struct {
const Held = struct { hash: []const u8, branch: []const u8 };
held: []const Held,
fn branchOf(ctx: *anyopaque, hash: []const u8) error{Unavailable}!?[]const u8 {
const self: *const ProtectStub = @ptrCast(@alignCast(ctx));
for (self.held) |h| if (std.mem.eql(u8, h.hash, hash)) return h.branch;
return null;
}
fn mainBranch(_: *anyopaque) error{Unavailable}![]const u8 {
return "trunk";
}
/// A held check-in carries its branch the way a derive leaves it: the
/// propagating `branch` and `sym-` pair, applied long before any push.
fn tagsOf(ctx: *anyopaque, gpa: std.mem.Allocator, hash: []const u8) error{ Unavailable, OutOfMemory }!?[]repostate.RepoState.TagEntry {
const self: *const ProtectStub = @ptrCast(@alignCast(ctx));
for (self.held) |h| {
if (!std.mem.eql(u8, h.hash, hash)) continue;
const sym = try std.fmt.allocPrint(gpa, "sym-{s}", .{h.branch});
const rows = try gpa.alloc(repostate.RepoState.TagEntry, 2);
rows[0] = .{ .name = "branch", .tagtype = .propagate, .value = h.branch, .mtime = protect_held_mtime };
rows[1] = .{ .name = sym, .tagtype = .propagate, .value = null, .mtime = protect_held_mtime };
return rows;
}
return null;
}
fn resolver(self: *const ProtectStub) sync.BranchResolver {
return .{ .ctx = @ptrCast(@constCast(self)), .branchOfFn = branchOf, .mainBranchFn = mainBranch, .tagsOfFn = tagsOf };
}
};
/// When the held check-ins' branch rows were applied: 2026-08-25, a day before
/// every artifact these tests push.
const protect_held_mtime: repostate.Mtime = 1787616000000;
fn protectUnavailable(_: *anyopaque, _: []const u8) error{Unavailable}!?[]const u8 {
return error.Unavailable;
}
fn protectUnavailableMain(_: *anyopaque) error{Unavailable}![]const u8 {
return error.Unavailable;
}
fn protectUnavailableTags(_: *anyopaque, _: std.mem.Allocator, _: []const u8) error{ Unavailable, OutOfMemory }!?[]repostate.RepoState.TagEntry {
return error.Unavailable;
}
/// An F target the manifests name; never held, which is fine — a missing
/// blob is a phantom, not a policy question.
const protect_blob = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// A check-in manifest. `parent` null makes a root; `tags` is the T run,
/// verbatim and already in §4.7.16 order.
fn protectCheckin(gpa: std.mem.Allocator, comment: []const u8, parent: ?[]const u8, tags: []const u8) ![]u8 {
const body = if (parent) |p|
try std.fmt.allocPrint(gpa, "C {s}\nD 2026-08-26T00:00:00\nF a.txt {s}\nP {s}\n{s}U u\n", .{ comment, protect_blob, p, tags })
else
try std.fmt.allocPrint(gpa, "C {s}\nD 2026-08-26T00:00:00\nF a.txt {s}\n{s}U u\n", .{ comment, protect_blob, tags });
defer gpa.free(body);
return protectSealed(gpa, body);
}
/// A control artifact carrying one T card aimed at a held check-in.
fn protectControl(gpa: std.mem.Allocator, tag_line: []const u8) ![]u8 {
const body = try std.fmt.allocPrint(gpa, "D 2026-08-26T00:00:00\n{s}\nU u\n", .{tag_line});
defer gpa.free(body);
return protectSealed(gpa, body);
}
/// A control artifact dated `date`, carrying the T run `tags` (sorted, one
/// card per line, no trailing newline).
fn protectControlAt(gpa: std.mem.Allocator, date: []const u8, tags: []const u8) ![]u8 {
const body = try std.fmt.allocPrint(gpa, "D {s}\n{s}\nU u\n", .{ date, tags });
defer gpa.free(body);
return protectSealed(gpa, body);
}
/// The move `amend --branch NEW` writes against `target`, leaving `old`.
fn protectMove(gpa: std.mem.Allocator, date: []const u8, target: []const u8, new: []const u8, old: []const u8) ![]u8 {
const tags = try std.fmt.allocPrint(gpa, "T *branch {s} {s}\nT *sym-{s} {s}\nT -sym-{s} {s}", .{ target, new, new, target, old, target });
defer gpa.free(tags);
return protectControlAt(gpa, date, tags);
}
/// `body` plus its Z card: the MD5 of every byte before it, lowercase hex —
/// the §4.7.19 self-checksum the import parser requires.
fn protectSealed(gpa: std.mem.Allocator, body: []const u8) ![]u8 {
var digest: [16]u8 = undefined;
std.crypto.hash.Md5.hash(body, &digest, .{});
var hex: [32]u8 = undefined;
const lower = "0123456789abcdef";
for (digest, 0..) |b, i| {
hex[i * 2] = lower[b >> 4];
hex[i * 2 + 1] = lower[b & 0x0f];
}
return std.fmt.allocPrint(gpa, "{s}Z {s}\n", .{ body, hex });
}
/// One push round of `artifacts`, in that order. Returns the reply.
fn protectPush(
gpa: std.mem.Allocator,
server: *const sync.Server,
store: repostate.ArtifactStore,
artifacts: []const []const u8,
out: *std.ArrayListUnmanaged(u8),
) ![]const u8 {
var req: std.ArrayListUnmanaged(u8) = .empty;
defer req.deinit(gpa);
var line: [256]u8 = undefined;
try req.appendSlice(gpa, try std.fmt.bufPrint(&line, "push {s} {s}\n", .{ h2_servercode, h2_projectcode }));
for (artifacts) |a| {
var hb: [64]u8 = undefined;
const h = artifact.artifactName(.sha3_256, a, &hb);
try req.appendSlice(gpa, try std.fmt.bufPrint(&line, "file {s} {d}\n", .{ h, a.len }));
try req.appendSlice(gpa, a);
}
out.clearRetainingCapacity();
return server.respond(gpa, store, req.items, out);
}
fn protectRefused(reply: []const u8, names: []const u8) bool {
return std.mem.startsWith(u8, reply, "error ") and std.mem.indexOf(u8, reply, names) != null;
}
test "sync: a check-in that would join a protected branch refuses the whole round; branching off and siblings pass" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
// Two held check-ins the resolver knows, and the store holds.
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_feat = try protectCheckin(gpa, "held\\son\\sfeature", null, "T *branch * feature\nT *sym-feature *\n");
defer gpa.free(on_feat);
var tb: [64]u8 = undefined;
var fb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_feat = artifact.artifactName(.sha3_256, on_feat, &fb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_feat, on_feat);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_feat, .branch = "feature" } } };
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = stub.resolver(),
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// A child of the trunk tip joins trunk: refused, naming the branch, and
// the round stored nothing.
const onto_trunk = try protectCheckin(gpa, "onto\\strunk", h_trunk, "");
defer gpa.free(onto_trunk);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{onto_trunk}, &out);
try std.testing.expect(protectRefused(reply, "join"));
try std.testing.expect(std.mem.indexOf(u8, reply, "'trunk'") != null);
try std.testing.expectEqual(before, ms.store().count());
}
// Branching OFF trunk without cancelling its name leaves `trunk`
// resolving to the new check-in: refused.
const keeps_name = try protectCheckin(gpa, "keeps\\sname", h_trunk, "T *branch * topic\nT *sym-topic *\n");
defer gpa.free(keeps_name);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{keeps_name}, &out);
try std.testing.expect(protectRefused(reply, "name"));
try std.testing.expectEqual(before, ms.store().count());
}
// Branching OFF trunk the way commit --branch does resolves to the new
// branch: admitted.
const off_trunk = try protectCheckin(gpa, "branch\\soff", h_trunk, "T *branch * topic\nT *sym-topic *\nT -sym-trunk *\n");
defer gpa.free(off_trunk);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{off_trunk}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
// A child of the feature tip is on feature: admitted.
const onto_feat = try protectCheckin(gpa, "onto\\sfeature", h_feat, "");
defer gpa.free(onto_feat);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{onto_feat}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
// A child of the feature tip that DECLARES trunk joins trunk by tag: refused.
const jump = try protectCheckin(gpa, "jump\\sto\\strunk", h_feat, "T *branch * trunk\nT *sym-trunk *\n");
defer gpa.free(jump);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{jump}, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// A root with no branch tag is on the main branch (§6.4): refused.
const root = try protectCheckin(gpa, "second\\sroot", null, "");
defer gpa.free(root);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{root}, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// A merge whose PRIMARY parent is on feature and whose secondary parent is
// the trunk tip is on feature: propagation follows the primary parent only
// (repo-state §4.2 step 4). Admitted.
const merge_parents = try std.fmt.allocPrint(gpa, "{s} {s}", .{ h_feat, h_trunk });
defer gpa.free(merge_parents);
const merge = try protectCheckin(gpa, "merge\\strunk\\sinto\\sfeature", merge_parents, "");
defer gpa.free(merge);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{merge}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
// A singleton `+sym-trunk` on the new check-in itself is a claim on the
// protected name that nothing legitimate mints: refused, even though its
// parent is on feature.
const claim = try protectCheckin(gpa, "claims\\strunk", h_feat, "T +sym-trunk *\n");
defer gpa.free(claim);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{claim}, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// A parent this server does not hold. Untagged, the derive puts the
// check-in on the main branch until that parent arrives, and the main
// branch is protected: refused, naming the parent to push first. With a
// branch of its own it joins that branch whatever its parent turns out
// to be: admitted.
const unheld = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
const orphan = try protectCheckin(gpa, "orphan", unheld, "");
defer gpa.free(orphan);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{orphan}, &out);
try std.testing.expect(protectRefused(reply, "ffffffffffff\\sfirst"));
try std.testing.expectEqual(before, ms.store().count());
}
const orphan_on_topic = try protectCheckin(gpa, "orphan\\son\\stopic", unheld, "T *branch * topic\nT *sym-topic *\n");
defer gpa.free(orphan_on_topic);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{orphan_on_topic}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
// A round mixing a conforming blob with one violation stores NOTHING —
// accepting the half that conforms would leave the client naming artifacts
// this server declined.
const blob = "plain content, not an artifact\n";
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ blob, onto_trunk }, &out);
try std.testing.expect(protectRefused(reply, "join"));
try std.testing.expectEqual(before, ms.store().count());
var bb: [64]u8 = undefined;
try std.testing.expect(ms.store().lookup(artifact.artifactName(.sha3_256, blob, &bb)) == null);
}
}
test "sync: a tag that moves a held check-in into or out of a protected branch is refused; other tags pass" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_feat = try protectCheckin(gpa, "held\\son\\sfeature", null, "T *branch * feature\nT *sym-feature *\n");
defer gpa.free(on_feat);
var tb: [64]u8 = undefined;
var fb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_feat = artifact.artifactName(.sha3_256, on_feat, &fb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_feat, on_feat);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_feat, .branch = "feature" } } };
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = stub.resolver(),
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const refused = [_]struct { line: []const u8, target: []const u8 }{
.{ .line = "T +sym-trunk ", .target = h_feat }, // joins by singleton
.{ .line = "T *sym-trunk ", .target = h_feat }, // joins, propagating
.{ .line = "T -sym-trunk ", .target = h_trunk }, // leaves
};
for (refused) |c| {
const card = try std.fmt.allocPrint(gpa, "{s}{s}", .{ c.line, c.target });
defer gpa.free(card);
const ctl = try protectControl(gpa, card);
defer gpa.free(ctl);
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ctl}, &out);
try std.testing.expect(protectRefused(reply, "membership"));
try std.testing.expect(std.mem.indexOf(u8, reply, "'trunk'") != null);
try std.testing.expectEqual(before, ms.store().count());
}
// `branch=trunk` by value is the same move.
{
const card = try std.fmt.allocPrint(gpa, "T *branch {s} trunk", .{h_feat});
defer gpa.free(card);
const ctl = try protectControl(gpa, card);
defer gpa.free(ctl);
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ctl}, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// Lifecycle is protected too: closing a trunk leaf shuts trunk for its
// owners, and reopening it is the same door. Both resolve through the
// target and are refused by name.
const lifecycle = [_]struct { line: []const u8, verb: []const u8 }{
.{ .line = "T +closed ", .verb = "close" },
.{ .line = "T *closed ", .verb = "close" },
.{ .line = "T -closed ", .verb = "reopen" },
};
for (lifecycle) |c| {
const card = try std.fmt.allocPrint(gpa, "{s}{s}", .{ c.line, h_trunk });
defer gpa.free(card);
const ctl = try protectControl(gpa, card);
defer gpa.free(ctl);
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ctl}, &out);
try std.testing.expect(protectRefused(reply, c.verb));
try std.testing.expect(std.mem.indexOf(u8, reply, "'trunk'") != null);
try std.testing.expectEqual(before, ms.store().count());
}
// Naming a release on a trunk leaf, closing a leaf OFF the protected
// branch, and closing a check-in this server does not hold are not
// protected moves: admitted. The line is lifecycle of a protected branch,
// not any tag that touches one.
const admitted = [_]struct { line: []const u8, target: []const u8 }{
.{ .line = "T +sym-v1.0 ", .target = h_trunk },
.{ .line = "T +closed ", .target = h_feat },
.{ .line = "T +closed ", .target = "0000000000000000000000000000000000000000000000000000000000000000" },
};
for (admitted) |c| {
const card = try std.fmt.allocPrint(gpa, "{s}{s}", .{ c.line, c.target });
defer gpa.free(card);
const ctl = try protectControl(gpa, card);
defer gpa.free(ctl);
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ctl}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
}
test "sync: a parent earlier or later in the same round resolves in-round, and a glob covers a branch family" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_rel = try protectCheckin(gpa, "held\\son\\srelease", null, "T *branch * release-1.0\nT *sym-release-1.0 *\n");
defer gpa.free(on_rel);
var tb: [64]u8 = undefined;
var rb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_rel = artifact.artifactName(.sha3_256, on_rel, &rb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_rel, on_rel);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_rel, .branch = "release-1.0" } } };
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk, release-*" },
.branch_of = stub.resolver(),
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// Off trunk onto `topic`, then a child of that: both on topic, whichever
// order the round lists them in.
const p_off = try protectCheckin(gpa, "off", h_trunk, "T *branch * topic\nT *sym-topic *\nT -sym-trunk *\n");
defer gpa.free(p_off);
var pb: [64]u8 = undefined;
const h_off = artifact.artifactName(.sha3_256, p_off, &pb);
const c_off = try protectCheckin(gpa, "child\\sof\\soff", h_off, "");
defer gpa.free(c_off);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ c_off, p_off }, &out); // child listed first
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
// A child of trunk and its own child: both would join trunk; refused as a
// round, whichever is judged first.
const p_on = try protectCheckin(gpa, "on", h_trunk, "");
defer gpa.free(p_on);
var qb: [64]u8 = undefined;
const h_on = artifact.artifactName(.sha3_256, p_on, &qb);
const c_on = try protectCheckin(gpa, "child\\sof\\son", h_on, "");
defer gpa.free(c_on);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ c_on, p_on }, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// The glob: a child of release-1.0 joins a protected family.
const onto_rel = try protectCheckin(gpa, "onto\\srelease", h_rel, "");
defer gpa.free(onto_rel);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{onto_rel}, &out);
try std.testing.expect(protectRefused(reply, "'release-1.0'"));
try std.testing.expectEqual(before, ms.store().count());
}
}
test "sync: a move in the same push counts only where the derive would apply it" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_feat = try protectCheckin(gpa, "held\\son\\sfeature", null, "T *branch * feature\nT *sym-feature *\n");
defer gpa.free(on_feat);
var tb: [64]u8 = undefined;
var fb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_feat = artifact.artifactName(.sha3_256, on_feat, &fb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_feat, on_feat);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_feat, .branch = "feature" } } };
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = stub.resolver(),
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const later = "2026-08-26T00:00:01";
// A check-in that names trunk itself, moved off it a second later:
// admitted, in either order the push lists them.
const names_trunk = try protectCheckin(gpa, "names\\strunk", h_feat, "T *branch * trunk\nT *sym-trunk *\nT -sym-feature *\n");
defer gpa.free(names_trunk);
var nb: [64]u8 = undefined;
const h_names = artifact.artifactName(.sha3_256, names_trunk, &nb);
const move_names = try protectMove(gpa, later, h_names, "other", "trunk");
defer gpa.free(move_names);
{
const reply = try protectPush(gpa, &server, ms.store(), &.{ move_names, names_trunk }, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().lookup(h_names) != null);
}
// A check-in on feature that the pusher moved onto trunk, then off it
// again: the later move wins, so admitted.
const plain = try protectCheckin(gpa, "moved\\stwice", h_feat, "");
defer gpa.free(plain);
var pb: [64]u8 = undefined;
const h_plain = artifact.artifactName(.sha3_256, plain, &pb);
const onto = try protectMove(gpa, later, h_plain, "trunk", "feature");
defer gpa.free(onto);
const off = try protectMove(gpa, "2026-08-26T00:00:02", h_plain, "other", "trunk");
defer gpa.free(off);
{
const reply = try protectPush(gpa, &server, ms.store(), &.{ plain, onto, off }, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().lookup(h_plain) != null);
}
// The same two moves the other way round: trunk is the later one.
const plain2 = try protectCheckin(gpa, "moved\\sback", h_feat, "");
defer gpa.free(plain2);
var p2b: [64]u8 = undefined;
const h_plain2 = artifact.artifactName(.sha3_256, plain2, &p2b);
const off2 = try protectMove(gpa, later, h_plain2, "other", "feature");
defer gpa.free(off2);
const onto2 = try protectMove(gpa, "2026-08-26T00:00:02", h_plain2, "trunk", "other");
defer gpa.free(onto2);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ plain2, off2, onto2 }, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// A child of trunk moved off it by a move dated BEFORE trunk's own branch
// row: linked first, the child inherits a newer row than the move, which
// then loses. Refused, though the move names another branch.
const child = try protectCheckin(gpa, "backdated", h_trunk, "");
defer gpa.free(child);
var cb: [64]u8 = undefined;
const h_child = artifact.artifactName(.sha3_256, child, &cb);
const backdated = try protectMove(gpa, "2026-08-24T00:00:00", h_child, "other", "trunk");
defer gpa.free(backdated);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ child, backdated }, &out);
try std.testing.expect(protectRefused(reply, "join"));
try std.testing.expectEqual(before, ms.store().count());
}
// Two moves of one check-in at the same moment: their order would decide,
// so refused.
const tied = try protectCheckin(gpa, "tied", h_feat, "");
defer gpa.free(tied);
var tdb: [64]u8 = undefined;
const h_tied = artifact.artifactName(.sha3_256, tied, &tdb);
const tie_a = try protectMove(gpa, later, h_tied, "other", "feature");
defer gpa.free(tie_a);
const tie_b = try protectMove(gpa, later, h_tied, "trunk", "feature");
defer gpa.free(tie_b);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ tied, tie_a, tie_b }, &out);
try std.testing.expect(protectRefused(reply, "carry"));
try std.testing.expectEqual(before, ms.store().count());
}
}
test "sync: a tag on a held check-in that moves it off, onto, or between protected branches by its branch row is refused" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_feat = try protectCheckin(gpa, "held\\son\\sfeature", null, "T *branch * feature\nT *sym-feature *\n");
defer gpa.free(on_feat);
var tb: [64]u8 = undefined;
var fb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_feat = artifact.artifactName(.sha3_256, on_feat, &fb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_feat, on_feat);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_feat, .branch = "feature" } } };
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = stub.resolver(),
};
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const refused = [_]struct { sign: []const u8, target: []const u8, value: []const u8 }{
// A trunk check-in moved to another branch leaves trunk.
.{ .sign = "*", .target = h_trunk, .value = " other" },
// A feature check-in whose branch row is cancelled falls back to the
// main branch, which is trunk.
.{ .sign = "-", .target = h_feat, .value = "" },
};
for (refused) |c| {
const card = try std.fmt.allocPrint(gpa, "T {s}branch {s}{s}", .{ c.sign, c.target, c.value });
defer gpa.free(card);
const ctl = try protectControl(gpa, card);
defer gpa.free(ctl);
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ctl}, &out);
try std.testing.expect(protectRefused(reply, "'trunk'"));
try std.testing.expectEqual(before, ms.store().count());
}
// Moving a feature check-in to another unprotected branch is not a
// protected move.
{
const card = try std.fmt.allocPrint(gpa, "T *branch {s} other", .{h_feat});
defer gpa.free(card);
const ctl = try protectControl(gpa, card);
defer gpa.free(ctl);
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ctl}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
}
/// A divert ledger in memory: which check-ins the login was refused for,
/// and what the server recorded and moved.
const LedgerStub = struct {
gpa: std.mem.Allocator,
refused: std.ArrayListUnmanaged([]const u8) = .empty,
recorded: std.ArrayListUnmanaged([]const u8) = .empty,
moves: std.ArrayListUnmanaged(sync.DivertMove) = .empty,
fn deinit(self: *LedgerStub) void {
for (self.recorded.items) |h| self.gpa.free(h);
for (self.moves.items) |m| {
self.gpa.free(m.hash);
self.gpa.free(m.branch);
self.gpa.free(m.protected);
}
self.refused.deinit(self.gpa);
self.recorded.deinit(self.gpa);
self.moves.deinit(self.gpa);
}
fn ledger(self: *LedgerStub) sync.DivertLedger {
return .{ .ctx = self, .refusedFn = refusedFn, .recordFn = recordFn, .divertedFn = divertedFn };
}
fn refusedFn(ctx: *anyopaque, hash: []const u8) bool {
const self: *LedgerStub = @ptrCast(@alignCast(ctx));
for (self.refused.items) |h| if (std.mem.eql(u8, h, hash)) return true;
return false;
}
fn recordFn(ctx: *anyopaque, hash: []const u8) error{OutOfMemory}!void {
const self: *LedgerStub = @ptrCast(@alignCast(ctx));
try self.recorded.append(self.gpa, try self.gpa.dupe(u8, hash));
}
fn divertedFn(ctx: *anyopaque, mv: sync.DivertMove) error{OutOfMemory}!void {
const self: *LedgerStub = @ptrCast(@alignCast(ctx));
try self.moves.append(self.gpa, .{
.hash = try self.gpa.dupe(u8, mv.hash),
.branch = try self.gpa.dupe(u8, mv.branch),
.protected = try self.gpa.dupe(u8, mv.protected),
});
}
};
/// The newest artifact in `store`: the move a diverted round adds last.
fn newestArtifact(store: repostate.ArtifactStore) []const u8 {
return store.content(@intCast(store.count())).?;
}
test "sync: a fronted divert refuses first, then keeps the pushed check-in on an attempt branch" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_feat = try protectCheckin(gpa, "held\\son\\sfeature", null, "T *branch * feature\nT *sym-feature *\n");
defer gpa.free(on_feat);
var tb: [64]u8 = undefined;
var fb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_feat = artifact.artifactName(.sha3_256, on_feat, &fb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_feat, on_feat);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_feat, .branch = "feature" } } };
var ledger = LedgerStub{ .gpa = gpa };
defer ledger.deinit();
// 2026-08-26T12:00:00Z, after every artifact here.
const now_ms: u64 = 1787745600000;
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = stub.resolver(),
.divert = .{ .login = "jan doe", .now_ms = now_ms, .ledger = ledger.ledger() },
};
const plain_server = sync.Server{ .version = h2_server_version, .push_policy = .{ .protect_globs = "trunk" }, .branch_of = stub.resolver() };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// Without the directive: refused, naming amend, promising nothing.
const onto = try protectCheckin(gpa, "onto\\strunk", h_trunk, "");
defer gpa.free(onto);
var ob: [64]u8 = undefined;
const h_onto = artifact.artifactName(.sha3_256, onto, &ob);
{
const reply = try protectPush(gpa, &plain_server, ms.store(), &.{onto}, &out);
try std.testing.expect(protectRefused(reply, "amend\\s"));
try std.testing.expect(std.mem.indexOf(u8, reply, "attempt/") == null);
}
// First push with it: refused, promising the attempt branch, recorded,
// nothing stored. The login is reduced to a branch-name segment.
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{onto}, &out);
try std.testing.expect(protectRefused(reply, "attempt/jan-doe/"));
try std.testing.expect(std.mem.indexOf(u8, reply, "not\\sauthorized\\sto\\swrite") == null);
try std.testing.expectEqual(before, ms.store().count());
try std.testing.expectEqual(@as(usize, 1), ledger.recorded.items.len);
try std.testing.expectEqualStrings(h_onto, ledger.recorded.items[0]);
}
// Pushed again after the refusal: admitted with the move, a message and
// the pragma; the check-in and one control artifact land.
try ledger.refused.append(gpa, h_onto);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{onto}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(std.mem.indexOf(u8, reply, "\nmessage check-in\\s") != null);
try std.testing.expect(std.mem.indexOf(u8, reply, "pragma zit-diverted ") != null);
try std.testing.expectEqual(before + 2, ms.store().count());
try std.testing.expectEqual(@as(usize, 1), ledger.moves.items.len);
try std.testing.expectEqualStrings("trunk", ledger.moves.items[0].protected);
try std.testing.expect(std.mem.startsWith(u8, ledger.moves.items[0].branch, "attempt/jan-doe/"));
var move = try artifact.parseForImport(gpa, newestArtifact(ms.store()));
defer move.deinit(gpa);
try std.testing.expect(move.manifest.kind == .control);
try std.testing.expectEqualStrings(sync.divert_user, move.manifest.u.?.login);
try std.testing.expect(repostate.parseMtime(move.manifest.d.?.timestamp).? > now_ms);
// Judged as an ordinary push, the check-in and its move join nothing.
const reply2 = try protectPush(gpa, &plain_server, ms.store(), &.{ onto, newestArtifact(ms.store()) }, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply2, "error "));
}
}
test "sync: a divert moves every shape of join, outdates a push from the future, and refuses a run nobody was told about" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const on_trunk = try protectCheckin(gpa, "held\\son\\strunk", null, "");
defer gpa.free(on_trunk);
const on_feat = try protectCheckin(gpa, "held\\son\\sfeature", null, "T *branch * feature\nT *sym-feature *\n");
defer gpa.free(on_feat);
var tb: [64]u8 = undefined;
var fb: [64]u8 = undefined;
const h_trunk = artifact.artifactName(.sha3_256, on_trunk, &tb);
const h_feat = artifact.artifactName(.sha3_256, on_feat, &fb);
_ = try ms.store().put(h_trunk, on_trunk);
_ = try ms.store().put(h_feat, on_feat);
const stub = ProtectStub{ .held = &.{ .{ .hash = h_trunk, .branch = "trunk" }, .{ .hash = h_feat, .branch = "feature" } } };
var ledger = LedgerStub{ .gpa = gpa };
defer ledger.deinit();
const server = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = stub.resolver(),
// Before the pushed artifacts: the move must still outdate them.
.divert = .{ .login = "jan", .now_ms = 1, .ledger = ledger.ledger() },
};
const plain_server = sync.Server{ .version = h2_server_version, .push_policy = .{ .protect_globs = "trunk" }, .branch_of = stub.resolver() };
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
// A check-in that names trunk itself, and a run under a trunk child whose
// second check-in names trunk again. One of the runs was never refused.
const names = try protectCheckin(gpa, "names\\strunk", h_feat, "T *branch * trunk\nT *sym-trunk *\nT -sym-feature *\n");
defer gpa.free(names);
var nb: [64]u8 = undefined;
const h_names = artifact.artifactName(.sha3_256, names, &nb);
const root = try protectCheckin(gpa, "run\\sroot", h_trunk, "");
defer gpa.free(root);
var rb: [64]u8 = undefined;
const h_root = artifact.artifactName(.sha3_256, root, &rb);
const again = try protectCheckin(gpa, "names\\strunk\\sagain", h_root, "T *branch * trunk\nT *sym-trunk *\n");
defer gpa.free(again);
var ab: [64]u8 = undefined;
const h_again = artifact.artifactName(.sha3_256, again, &ab);
try ledger.refused.append(gpa, h_names);
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{ names, root, again }, &out);
try std.testing.expect(protectRefused(reply, "attempt/jan/"));
try std.testing.expect(std.mem.indexOf(u8, reply, h_root[0..12]) != null);
try std.testing.expectEqual(before, ms.store().count());
try std.testing.expectEqual(@as(usize, 1), ledger.recorded.items.len);
try std.testing.expectEqualStrings(h_root, ledger.recorded.items[0]);
}
// Both runs on record: one move carries all three check-ins, two runs.
try ledger.refused.append(gpa, h_root);
{
const reply = try protectPush(gpa, &server, ms.store(), &.{ names, root, again }, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expectEqual(@as(usize, 2), ledger.moves.items.len);
const move_bytes = newestArtifact(ms.store());
var move = try artifact.parseForImport(gpa, move_bytes);
defer move.deinit(gpa);
var targets: usize = 0;
for (move.manifest.t) |t| {
if (!std.mem.eql(u8, t.name, "branch")) continue;
targets += 1;
const want_root = if (std.mem.eql(u8, t.target.?, h_names)) h_names else h_root;
try std.testing.expect(std.mem.endsWith(u8, t.value.?, want_root[0..12]));
}
try std.testing.expectEqual(@as(usize, 3), targets);
_ = h_again;
const reply2 = try protectPush(gpa, &plain_server, ms.store(), &.{ names, root, again, move_bytes }, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply2, "error "));
}
// A push dated at the very end of year 9999 leaves no later date to
// write, so it cannot be moved: refused, never a crash.
const last_ms = "C last\\sdate\nD 9999-12-31T23:59:59.999\nF a.txt " ++ protect_blob ++ "\nP ";
const last_body = try std.fmt.allocPrint(gpa, "{s}{s}\nU u\n", .{ last_ms, h_trunk });
defer gpa.free(last_body);
const last = try protectSealed(gpa, last_body);
defer gpa.free(last);
var lb: [64]u8 = undefined;
try ledger.refused.append(gpa, artifact.artifactName(.sha3_256, last, &lb));
{
const before = ms.store().count();
const reply = try protectPush(gpa, &server, ms.store(), &.{last}, &out);
try std.testing.expect(protectRefused(reply, "join"));
try std.testing.expectEqual(before, ms.store().count());
}
}
test "sync: protected globs with no resolver, or a resolver that cannot answer, refuse rather than admit; no globs is inert" {
const gpa = std.testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const root = try protectCheckin(gpa, "root", null, "");
defer gpa.free(root);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const no_resolver = sync.Server{ .version = h2_server_version, .push_policy = .{ .protect_globs = "trunk" } };
{
const before = ms.store().count();
const reply = try protectPush(gpa, &no_resolver, ms.store(), &.{root}, &out);
try std.testing.expect(protectRefused(reply, "cannot"));
try std.testing.expectEqual(before, ms.store().count());
}
var token: u8 = 0;
const broken = sync.Server{
.version = h2_server_version,
.push_policy = .{ .protect_globs = "trunk" },
.branch_of = .{ .ctx = &token, .branchOfFn = protectUnavailable, .mainBranchFn = protectUnavailableMain, .tagsOfFn = protectUnavailableTags },
};
{
const before = ms.store().count();
const reply = try protectPush(gpa, &broken, ms.store(), &.{root}, &out);
try std.testing.expect(protectRefused(reply, "cannot"));
try std.testing.expectEqual(before, ms.store().count());
}
// Nothing declared: the server every operator has today.
const inert = sync.Server{ .version = h2_server_version };
{
const before = ms.store().count();
const reply = try protectPush(gpa, &inert, ms.store(), &.{root}, &out);
try std.testing.expect(!std.mem.startsWith(u8, reply, "error "));
try std.testing.expect(ms.store().count() > before);
}
}