Artifact 24465d21f5c2dc06d2892e505d01d98dd03725cd5c06e477f25b144bf2cee815:
- File
src/sync_test.zig
— part of check-in
[1c41f96e86]
at
2026-07-30 00:25:19
on branch shun-implementation
— shun S5: never gimme a name in the shun table
sync SS3.3 L527-530 excludes shun-table names from the ordinary phantom scan. The predicate already existed and its call site hardcoded false; S5 supplies the durable state through a borrowed ShunSet whose .none identity leaves every existing construction site unchanged. The literal reading is kept: an igot for a shunned hash still CREATES the phantom (SS3.3 L511-514) -- it is simply never requested, so the phantom is inert and permanent.
Three consequences the reviews forced out:
A shunned phantom no longer counts as unresolved. It was told about and refused, not asked for and withheld; counting it made zit pull fail on every future pull, since the peer re-announces each round.
The server consults the set too. SS3.3 states the filter on the phantom scan without qualifying a side, and a server under push-receive is a receiver, so an inbound igot could otherwise walk a shunned artifact straight back in.
parseIgotCard now rejects a name that is not 40 or 64 hex, as parseGimmeCard already did (artifact-format SS3.2 L349-350). Widening the shun predicate to answer false for an out-of-domain name would have been the fail-open fix. (user: dmestas, size: 420732) [annotate] [blame] [check-ins using]
//! Unit tests for the sync protocol (draft-fossil-sync-protocol-00). //! //! Separated from the implementation per the kind-separation convention //! (mirrors the sibling `*_fuzz.zig` split). Every test here exercises only //! `sync`'s public surface; tests reaching file-private decls stay inline in //! `sync.zig`. const std = @import("std"); const artifact = @import("artifact.zig"); const repostate = @import("repostate.zig"); const delta = @import("delta.zig"); const sync = @import("sync.zig"); const 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 Side = sync.Side; const SignalKind = sync.SignalKind; const SyncProjectCode = sync.SyncProjectCode; const TimestampComment = sync.TimestampComment; const Tokens = sync.Tokens; const Transport = sync.Transport; const UvGimmeReply = sync.UvGimmeReply; const UvLanding = sync.UvLanding; const UvLocalAction = sync.UvLocalAction; const UvResponse = sync.UvResponse; const UvRow = sync.UvRow; const UvSendTable = sync.UvSendTable; const UvSession = sync.UvSession; const UvStatus = sync.UvStatus; const UvUpload = sync.UvUpload; const UvfileCard = sync.UvfileCard; const VersionInfo = sync.VersionInfo; const admitInbound = sync.admitInbound; const capabilityFromLetter = sync.capabilityFromLetter; const capabilitySetFromLetters = sync.capabilitySetFromLetters; const causeIsFatal = sync.causeIsFatal; const checkRedirectDowngrade = sync.checkRedirectDowngrade; const checkRequestOrder = sync.checkRequestOrder; const classifyCard = sync.classifyCard; const classifyPragma = sync.classifyPragma; const clientMaySendUvHash = sync.clientMaySendUvHash; const clientOnErrorCard = sync.clientOnErrorCard; const clientOnMessageCard = sync.clientOnMessageCard; const clientOnUvPullOnly = sync.clientOnUvPullOnly; const clientOnUvPushOk = sync.clientOnUvPushOk; const clientSendsCiUnlock = sync.clientSendsCiUnlock; const clientSendsContent = sync.clientSendsContent; const clientSendsSendPrivate = sync.clientSendsSendPrivate; const cloneSeqnoIsFatal = sync.cloneSeqnoIsFatal; const cloneServerMustAdvance = sync.cloneServerMustAdvance; const clone_version = sync.clone_version; const commitMayProceedAfterCiLockFail = sync.commitMayProceedAfterCiLockFail; const computeNonce = sync.computeNonce; const computeSignature = sync.computeSignature; const configKind = sync.configKind; const configNameHasSlashPrefix = sync.configNameHasSlashPrefix; const config_push_unauthorized_text = sync.config_push_unauthorized_text; const config_scalar_marker = sync.config_scalar_marker; const consumeConfigPayload = sync.consumeConfigPayload; const consumeContentPayload = sync.consumeContentPayload; const consumeUvfilePayload = sync.consumeUvfilePayload; const continueExchange = sync.continueExchange; const cookie_gate_min_date = sync.cookie_gate_min_date; const cookie_gate_min_time = sync.cookie_gate_min_time; const cookie_gate_min_version = sync.cookie_gate_min_version; const deltaBasisSend = sync.deltaBasisSend; const deriveSharedSecret = sync.deriveSharedSecret; const duplicateLogin = sync.duplicateLogin; const encodeVersion = sync.encodeVersion; const establishedCapabilities = sync.establishedCapabilities; const fossilQuote = sync.fossilQuote; const fossilUnquote = sync.fossilUnquote; const gimmeEligible = sync.gimmeEligible; const gimmeResponse = sync.gimmeResponse; const igotDisposition = sync.igotDisposition; const initialState = sync.initialState; const integritySchedule = sync.integritySchedule; const isCommentLine = sync.isCommentLine; const isFatal = sync.isFatal; const isFossilWhitespace = sync.isFossilWhitespace; const loginTransport = sync.loginTransport; const matchesPullOnly = sync.matchesPullOnly; const max_line_len = sync.max_line_len; const max_redirects = sync.max_redirects; const max_tokens = sync.max_tokens; const media_props = sync.media_props; const needsSshTransportHeader = sync.needsSshTransportHeader; const nextRequestKind = sync.nextRequestKind; const parseCfileCard = sync.parseCfileCard; const parseCiLock = sync.parseCiLock; const parseCiLockFail = sync.parseCiLockFail; const parseCiUnlock = sync.parseCiUnlock; const parseClientUrl = sync.parseClientUrl; const parseCloneSeqno = sync.parseCloneSeqno; const parseConfigCard = sync.parseConfigCard; const parseCookieLogin = sync.parseCookieLogin; const parseFileCard = sync.parseFileCard; const parseGimmeCard = sync.parseGimmeCard; const parseIgotCard = sync.parseIgotCard; const parseLoginCard = sync.parseLoginCard; const parseMediaType = sync.parseMediaType; const parseOperator = sync.parseOperator; const parsePragmaName = sync.parsePragmaName; const parsePushPull = sync.parsePushPull; const parseReqconfigName = sync.parseReqconfigName; const parseScalarConfig = sync.parseScalarConfig; const parseSignalText = sync.parseSignalText; const parseTimestampComment = sync.parseTimestampComment; const parseUvHash = sync.parseUvHash; const parseUvfileCard = sync.parseUvfileCard; const parseUvgimmeCard = sync.parseUvgimmeCard; const parseUvigotCard = sync.parseUvigotCard; const parseVersionPragma = sync.parseVersionPragma; const peerGatedForCookie = sync.peerGatedForCookie; const peer_version_error_text = sync.peer_version_error_text; const pragmaDirection = sync.pragmaDirection; const pragmaIsArgless = sync.pragmaIsArgless; const pragmaSchedule = sync.pragmaSchedule; const privateIgotAction = sync.privateIgotAction; const privateReceipt = sync.privateReceipt; const private_igot_min_peer_date = sync.private_igot_min_peer_date; const readLine = sync.readLine; const receivePrivateIgot = sync.receivePrivateIgot; const recordPeerVersion = sync.recordPeerVersion; const redirectAllowed = sync.redirectAllowed; const redirectAllowedForTransport = sync.redirectAllowedForTransport; const replyBodyStep = sync.replyBodyStep; const replyCapCheck = sync.replyCapCheck; const replyFraming = sync.replyFraming; const reply_body_cap = sync.reply_body_cap; const requiredCapability = sync.requiredCapability; const send_private_unauthorized_text = sync.send_private_unauthorized_text; const sendableAfterCloneRound0 = sync.sendableAfterCloneRound0; const serverCapabilityDecision = sync.serverCapabilityDecision; const serverCiLockResult = sync.serverCiLockResult; const serverConfigPushDecision = sync.serverConfigPushDecision; const serverEmitsServerVersion = sync.serverEmitsServerVersion; const serverErrorReset = sync.serverErrorReset; const serverMethodDisposition = sync.serverMethodDisposition; const serverReqconfigDisposition = sync.serverReqconfigDisposition; const serverSendPrivateDecision = sync.serverSendPrivateDecision; const serverSyncProjectCodeDecision = sync.serverSyncProjectCodeDecision; const serverUvCatalog = sync.serverUvCatalog; const serverUvResponse = sync.serverUvResponse; const serverVerifyLogin = sync.serverVerifyLogin; const sha3PeerVersionGate = sync.sha3PeerVersionGate; const sha3_min_peer_version = sync.sha3_min_peer_version; const ssh_transport_header = sync.ssh_transport_header; const stripRedirectXfer = sync.stripRedirectXfer; const syncRequestTarget = sync.syncRequestTarget; const tokenizeCard = sync.tokenizeCard; const transition = sync.transition; const uvCatalogHash = sync.uvCatalogHash; const uvDecide = sync.uvDecide; const uvGimmeReply = sync.uvGimmeReply; const uvLandDecision = sync.uvLandDecision; const uvLandingClearsCatalog = sync.uvLandingClearsCatalog; const uvStatus = sync.uvStatus; const uvfilePayloadFollows = sync.uvfilePayloadFollows; const uvfile_flag_omitted = sync.uvfile_flag_omitted; const verifyUvContent = sync.verifyUvContent; const writeBadCommand = sync.writeBadCommand; const writeCiLock = sync.writeCiLock; const writeCiLockFail = sync.writeCiLockFail; const writeCiUnlock = sync.writeCiUnlock; const writeClientUrl = sync.writeClientUrl; const writeClientVersion = sync.writeClientVersion; const writeCloneCard = sync.writeCloneCard; const writeConfigCard = sync.writeConfigCard; const writeCookieCard = sync.writeCookieCard; const writeCookieLogin = sync.writeCookieLogin; const writeLogin = sync.writeLogin; const writeFlagPragma = sync.writeFlagPragma; const writeMalformedCard = sync.writeMalformedCard; const writePushPullCard = sync.writePushPullCard; const writeReqconfig = sync.writeReqconfig; const writeScalarConfig = sync.writeScalarConfig; const writeServerVersion = sync.writeServerVersion; const writeSignalCard = sync.writeSignalCard; const zlibContainerDecode = 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 §5.2/§12.3-r2: card and comment lines bounded at 65535 bytes including LF" { // §12.3-r2 L2755-2760: the 65535-byte line bound is a fatal-error bound — // an over-length line (no LF within the window) is MissingTerminator, the // resource-exhaustion guard restated here. var ok_buf: [max_line_len]u8 = undefined; @memset(ok_buf[0 .. max_line_len - 1], 'x'); ok_buf[max_line_len - 1] = '\n'; const ok_line = try readLine(&ok_buf, 0); try std.testing.expectEqual(@as(usize, max_line_len - 1), ok_line.bytes.len); var over_buf: [max_line_len]u8 = undefined; @memset(&over_buf, 'x'); try std.testing.expectError(LineError.MissingTerminator, readLine(&over_buf, 0)); } test "sync §12.3-r1: the line bound is measured through LF, excluding the following byte-counted payload" { // §12.3 L2757-2758: the bound is measured through the terminating LF and // excludes any following byte-counted payload. A short card line followed // by a payload larger than the whole 65535 bound still parses — readLine // stops at the early LF and never scans the payload. const hash = "a" ** 40; const card_line = "file " ++ hash ++ " 100000\n"; // 53 bytes, LF at index 52 const payload_len: usize = max_line_len + 64; var body: [card_line.len + payload_len]u8 = undefined; @memcpy(body[0..card_line.len], card_line); @memset(body[card_line.len..], 'x'); // non-LF byte-counted payload const line = try readLine(&body, 0); try std.testing.expectEqual(@as(usize, card_line.len - 1), line.bytes.len); // 52, excludes LF try std.testing.expectEqual(@as(usize, card_line.len), line.next); // 53, just past LF // The trailing payload exceeds the bound, yet readLine succeeded: it found // the LF within the window without scanning into the payload. try std.testing.expect(body.len - line.next > max_line_len); } test "sync §12.3-r3: an over-length line aborts before card dispatch (replyBodyStep .truncated, never .card_ready)" { // §12.3 L2760-2762: the receiver MUST abort before interpreting an // over-length line as a card or consuming any subsequent payload. // replyBodyStep runs readLine before it can return .card_ready, so an // unterminated over-length line yields .truncated and never dispatches. const over: usize = max_line_len + 50; var body: [over + 1]u8 = undefined; @memset(body[0..over], 'x'); // no LF within the window body[over] = '\n'; // LF sits beyond the bound try std.testing.expectEqual(ReplyBodyStep.truncated, replyBodyStep(&body, 0)); try std.testing.expect(replyBodyStep(&body, 0) != .card_ready); try std.testing.expectError(LineError.MissingTerminator, readLine(&body, 0)); } test "sync §12.3-r4: a declared 70000-byte count advances past the 65535 line bound" { // §12.3 L2763-2766: the line bound does not cap the SIZE/CSIZE payloads of // file/cfile/uvfile/config cards, which remain governed by their declared // byte counts. 70000 is the smallest round value above the 65535 bound. const hash = "a" ** 40; const card_line = "file " ++ hash ++ " 70000\n"; // 52 bytes const declared: usize = 70000; const sentinel = "pong\n"; var body: [card_line.len + declared + sentinel.len]u8 = undefined; @memcpy(body[0..card_line.len], card_line); @memset(body[card_line.len..][0..declared], 'y'); // non-LF declared payload @memcpy(body[card_line.len + declared ..], sentinel); const first = try readLine(&body, 0); try std.testing.expectEqualStrings("file " ++ hash ++ " 70000", first.bytes); // Caller advances past the declared byte count, not by re-scanning for LF. const advance = first.next + declared; const second = try readLine(&body, advance); try std.testing.expectEqualStrings("pong", second.bytes); } test "sync §5.2: unknown card operator is a fatal unknown-operator disposition" { const c = classifyCard("frobnicate x"); try std.testing.expectEqual(Disposition.unknown_operator, c.disposition); try std.testing.expect(c.operator == null); try std.testing.expect(isFatal(c.disposition, .client)); try std.testing.expect(isFatal(c.disposition, .server)); } test "sync §5.2: defined operator with in-window arg count is dispatched not unknown" { const c = classifyCard("private extra"); try std.testing.expectEqual(Operator.private, c.operator.?); try std.testing.expectEqual(Disposition.dispatch, c.disposition); } test "sync §5.2: card with surplus tokens beyond the six-slot window is malformed" { const t = tokenizeCard("push a b c d e f"); try std.testing.expectEqual(@as(usize, max_tokens), t.len); try std.testing.expect(t.surplus); const c = classifyCard("push a b c d e f"); try std.testing.expect(c.disposition != .dispatch); } test "sync §5.2: receiver treats surplus tokens as malformed" { const simple = classifyCard("push a b c d e f"); try std.testing.expectEqual(Disposition.bad_command, simple.disposition); const content = classifyCard("file a b c d e f"); try std.testing.expectEqual(Disposition.malformed_content, content.disposition); } test "sync §5.2: content card arity failure yields malformed CARD line and aborts" { const c = classifyCard("file a b c d e f"); try std.testing.expectEqual(Disposition.malformed_content, c.disposition); var out: [64]u8 = undefined; const msg = writeMalformedCard(.file, &out); try std.testing.expectEqualStrings("malformed file line", msg); try std.testing.expect(isFatal(c.disposition, .client)); try std.testing.expect(isFatal(c.disposition, .server)); } test "sync §5.2: simple card arity failure yields bad command, fatal client non-fatal server" { const line = "push a b c d e f"; const c = classifyCard(line); try std.testing.expectEqual(Disposition.bad_command, c.disposition); var out: [64]u8 = undefined; const msg = writeBadCommand(line, &out); try std.testing.expectEqualStrings("bad command: push a b c d e f", msg); try std.testing.expect(isFatal(c.disposition, .client)); try std.testing.expect(!isFatal(c.disposition, .server)); } test "sync §5.2: unrecognized card framing is exactly 'bad command:' plus offending line" { var out: [64]u8 = undefined; const msg = writeBadCommand("frobnicate x y", &out); try std.testing.expectEqualStrings("bad command: frobnicate x y", msg); try std.testing.expect(std.mem.indexOf(u8, msg, "Unknown message") == null); } test "sync §5.2.1: defined operator catalog resolves client/server operators" { try std.testing.expectEqual(Operator.cookie, parseOperator("cookie").?); try std.testing.expectEqual(Operator.login, parseOperator("login").?); try std.testing.expectEqual(Operator.push, parseOperator("push").?); try std.testing.expectEqual(Operator.pull, parseOperator("pull").?); } test "sync §5.2.1: remaining operators resolve and catalog size is 19" { const names = [_][]const u8{ "clone", "file", "cfile", "uvfile", "private", "igot", "uvigot", "gimme", "uvgimme", "reqconfig", "config", "clone_seqno", "pragma", "error", "message", }; for (names) |name| { try std.testing.expect(parseOperator(name) != null); } try std.testing.expectEqual(@as(usize, 19), @typeInfo(Operator).@"enum".fields.len); } test "sync §5.3: a line whose first byte is # is a comment line" { try std.testing.expect(isCommentLine("#x")); try std.testing.expect(!isCommentLine("")); try std.testing.expect(!isCommentLine("push")); } test "sync §5.3: comment lines are not dispatched as cards" { // A comment line may textually contain an operator name, but its // leading # is what a card dispatcher sees as the first token, and // "#" is not in the operator catalog -- so even a comment line that // reads like a push card can never resolve to Operator.push // (§5-r22). try std.testing.expect(isCommentLine("# push a b")); const c = classifyCard("# push a b"); try std.testing.expectEqual(Disposition.unknown_operator, c.disposition); try std.testing.expect(c.operator == null); } test "sync §5.3: timestamp subform exposes DATETIME and informational error count" { const tc = parseTimestampComment("# timestamp 2026-07-02T18:31:13 errors 0").?; try std.testing.expectEqualStrings("2026-07-02T18:31:13", tc.datetime); try std.testing.expectEqual(@as(u64, 0), tc.errors); } test "sync §5.3: other comment lines are not semantically interpreted" { try std.testing.expect(parseTimestampComment("# F2A895EF75B47B7157664A7FEAC978EFFFDB01C4") == null); try std.testing.expect(parseTimestampComment("# sending-clusters") == null); try std.testing.expect(parseTimestampComment("# timestamp X errors zz") == null); } test "sync §10 r1: a pragma card is 'pragma NAME [ARGUMENTS]'" { const c1 = classifyPragma(tokenizeCard("pragma send-catalog")); try std.testing.expectEqual(PragmaName.@"send-catalog", c1.name.?); const c2 = classifyPragma(tokenizeCard("pragma ci-lock H ID")); try std.testing.expectEqual(PragmaName.@"ci-lock", c2.name.?); } test "sync §10 r2: an unrecognised pragma name is silently ignored" { try std.testing.expect(parsePragmaName("future-x") == null); const c = classifyPragma(tokenizeCard("pragma future-x arg")); try std.testing.expect(c.name == null); } test "sync §10 r3: unknown pragma name is ignored but unknown card operator is fatal" { const c = classifyPragma(tokenizeCard("pragma future-x")); try std.testing.expect(c.name == null); const bad = classifyCard("frobnicate x"); try std.testing.expectEqual(Disposition.unknown_operator, bad.disposition); try std.testing.expect(isFatal(bad.disposition, .client)); try std.testing.expect(isFatal(bad.disposition, .server)); } test "sync §3.5: a pragma is an ordinary body card parsed in round order [sync-3-5-r1]" { // L693-694 — pragmas are ordinary body cards parsed in round order // along with all other body cards; the pragma dispatches at its // positional offset, not pre-filtered out of band. const body = "igot H1\npragma client-version 20000\ngimme H2\n"; const want = [_]Operator{ .igot, .pragma, .gimme }; var offset: usize = 0; for (want) |op| { try std.testing.expectEqual(ReplyBodyStep.card_ready, replyBodyStep(body, offset)); const line = try readLine(body, offset); const cc = classifyCard(line.bytes); try std.testing.expectEqual(op, cc.operator.?); try std.testing.expectEqual(Disposition.dispatch, cc.disposition); offset = line.next; } try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, offset)); } test "sync §3.5: an unknown pragma name is not a protocol error [sync-3-5-r2]" { // L694-696 — unknown pragma names are silently ignored and MUST NOT be // treated as protocol errors. The pragma card still dispatches (the // operator resolves); only the NAME is unrecognized. const cc = classifyCard("pragma future-x arg"); try std.testing.expectEqual(Operator.pragma, cc.operator.?); try std.testing.expectEqual(Disposition.dispatch, cc.disposition); try std.testing.expect(!isFatal(cc.disposition, .client)); try std.testing.expect(!isFatal(cc.disposition, .server)); try std.testing.expect(classifyPragma(cc.tokens).name == null); } test "sync §3.5: an unknown non-pragma operator is a fatal protocol error [sync-3-5-r3]" { // L696 — an unknown non-pragma operator remains a fatal protocol error // (§5-r12: unknown operator is fatal on either side). const cc = classifyCard("frobnicate x y"); try std.testing.expect(cc.operator == null); try std.testing.expectEqual(Disposition.unknown_operator, cc.disposition); try std.testing.expect(isFatal(cc.disposition, .client)); try std.testing.expect(isFatal(cc.disposition, .server)); } test "sync §10 r4: fourteen pragma names, nine c2s and five s2c" { const Case = struct { name: []const u8, dir: PragmaDirection }; const cases = [_]Case{ .{ .name = "send-private", .dir = .client_to_server }, .{ .name = "send-catalog", .dir = .client_to_server }, .{ .name = "client-version", .dir = .client_to_server }, .{ .name = "uv-hash", .dir = .client_to_server }, .{ .name = "ci-lock", .dir = .client_to_server }, .{ .name = "ci-unlock", .dir = .client_to_server }, .{ .name = "client-url", .dir = .client_to_server }, .{ .name = "req-links", .dir = .client_to_server }, .{ .name = "req-clusters", .dir = .client_to_server }, .{ .name = "server-version", .dir = .server_to_client }, .{ .name = "uv-push-ok", .dir = .server_to_client }, .{ .name = "uv-pull-only", .dir = .server_to_client }, .{ .name = "ci-lock-fail", .dir = .server_to_client }, .{ .name = "avoid-delta-manifests", .dir = .server_to_client }, }; try std.testing.expectEqual(@as(usize, 14), cases.len); var c2s: usize = 0; var s2c: usize = 0; for (cases) |c| { const name = parsePragmaName(c.name).?; try std.testing.expectEqual(c.dir, pragmaDirection(name)); switch (c.dir) { .client_to_server => c2s += 1, .server_to_client => s2c += 1, } } try std.testing.expectEqual(@as(usize, 9), c2s); try std.testing.expectEqual(@as(usize, 5), s2c); } test "sync §10 r5: pragma resolution is not version-gated" { const names = [_][]const u8{ "send-private", "send-catalog", "client-version", "uv-hash", "ci-lock", "ci-unlock", "client-url", "req-links", "req-clusters", "server-version", "uv-push-ok", "uv-pull-only", "ci-lock-fail", "avoid-delta-manifests", }; for (names) |name| { try std.testing.expect(parsePragmaName(name) != null); } try std.testing.expect(parsePragmaName("unknown-thing") == null); } test "sync §10.1 r6: client-version is 'pragma client-version VERSION DATE TIME'" { var buf: [64]u8 = undefined; const v: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } }; try std.testing.expectEqualStrings("pragma client-version 20000 20260709 120000", try writeClientVersion(v, &buf)); } test "sync §10.1 r7: VERSION = MAJOR*10000+MINOR*100+PATCH; 2.0 -> 20000" { try std.testing.expectEqual(@as(u32, 20000), encodeVersion(2, 0, 0)); try std.testing.expectEqual(@as(u32, 19999), encodeVersion(1, 99, 99)); try std.testing.expect(encodeVersion(1, 99, 99) < 20000); } test "sync §10.1 r8: DATE and TIME are optional; VERSION-only is allowed" { var buf: [64]u8 = undefined; const v: VersionInfo = .{ .version = 20000 }; try std.testing.expectEqualStrings("pragma client-version 20000", try writeClientVersion(v, &buf)); const parsed = try parseVersionPragma(tokenizeCard("pragma client-version 20000")); try std.testing.expect(parsed.datetime == null); } test "sync §10.1 r9: received client-version updates server peer version and timestamp" { var p = Peer.serverInit(); recordPeerVersion(&p, .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } }); try std.testing.expectEqual(@as(u32, 20000), p.version); try std.testing.expectEqual(@as(u32, 20260709), p.datetime.?.date); try std.testing.expectEqual(@as(u32, 120000), p.datetime.?.time); } test "sync §10.1 r10: before client-version, server peer VERSION is 0" { const p = Peer.serverInit(); try std.testing.expectEqual(@as(u32, 0), p.version); try std.testing.expect(p.datetime == null); } test "sync §10.1 r11: server emits server-version iff pragma had DATE and TIME (>=5 tokens)" { const v = try parseVersionPragma(tokenizeCard("pragma client-version 20000 20260709 120000")); try std.testing.expect(serverEmitsServerVersion(v)); } test "sync §10.1 r12: VERSION-only client-version suppresses server-version" { const v = try parseVersionPragma(tokenizeCard("pragma client-version 20000")); try std.testing.expect(!serverEmitsServerVersion(v)); } test "sync §10.1 r13: received server-version updates client peer version and timestamp" { var p = Peer.clientInit(19999); recordPeerVersion(&p, .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } }); try std.testing.expectEqual(@as(u32, 20000), p.version); try std.testing.expectEqual(@as(u32, 20260709), p.datetime.?.date); } test "sync §10.1 r14: before server-version, client peer VERSION is its own release integer" { const p = Peer.clientInit(19999); try std.testing.expectEqual(@as(u32, 19999), p.version); try std.testing.expect(p.datetime == null); } test "sync §10.1: server-version DATE/TIME zero-padded to YYYYMMDD/HHMMSS form" { var buf: [64]u8 = undefined; const v1: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 90503 } }; try std.testing.expectEqualStrings("pragma server-version 20000 20260709 090503", try writeServerVersion(v1, &buf)); var buf2: [64]u8 = undefined; const v2: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 1 } }; try std.testing.expectEqualStrings("pragma server-version 20000 20260709 000001", try writeServerVersion(v2, &buf2)); } test "sync §10.1: client-version numeric fields must parse (rejection)" { const cases = [_][]const u8{ "pragma client-version", "pragma client-version x", "pragma client-version 20000 x 120000", "pragma client-version 20000 20260709 x", }; for (cases) |line| { try std.testing.expectError(PragmaError.Malformed, parseVersionPragma(tokenizeCard(line))); } } test "sync §10.2 r15: uv-hash carries the client's catalog hash" { const t = tokenizeCard("pragma uv-hash ABC123"); try std.testing.expectEqualStrings("ABC123", parseUvHash(t).?); const c = classifyPragma(t); try std.testing.expectEqual(PragmaName.@"uv-hash", c.name.?); } test "sync §10.2 r16: uv-hash sent at most once per session and never on clone round 0" { const Case = struct { hash_sent: bool, op: Operation, round: u64, want: bool }; const cases = [_]Case{ .{ .hash_sent = false, .op = .sync, .round = 3, .want = true }, .{ .hash_sent = true, .op = .sync, .round = 3, .want = false }, .{ .hash_sent = false, .op = .clone, .round = 0, .want = false }, .{ .hash_sent = false, .op = .clone, .round = 1, .want = true }, }; for (cases) |c| { try std.testing.expectEqual(c.want, clientMaySendUvHash(c.hash_sent, c.op, c.round)); } } test "sync §10.2 r17: matching catalog hash suppresses the uv response" { try std.testing.expectEqual(UvResponse.none, serverUvResponse(true, true, true)); } test "sync §10.2 r18: differing hash with read yields push-ok or pull-only" { const Case = struct { match: bool, read: bool, upload: bool, want: UvResponse }; const cases = [_]Case{ .{ .match = false, .read = true, .upload = true, .want = .push_ok }, .{ .match = false, .read = true, .upload = false, .want = .pull_only }, .{ .match = false, .read = false, .upload = true, .want = .none }, }; for (cases) |c| { try std.testing.expectEqual(c.want, serverUvResponse(c.match, c.read, c.upload)); } } test "sync §10.2 r19: uv-pull-only latches the client and push-ok cannot undo it" { var s: UvSession = .{}; try std.testing.expectEqual(UvUpload.undetermined, s.upload); clientOnUvPushOk(&s); try std.testing.expectEqual(UvUpload.push_ok, s.upload); clientOnUvPullOnly(&s); try std.testing.expectEqual(UvUpload.pull_only, s.upload); clientOnUvPushOk(&s); try std.testing.expectEqual(UvUpload.pull_only, s.upload); } test "sync §10.3 r20: reqconfig NAME must begin with /" { try std.testing.expect(configNameHasSlashPrefix("/ui")); try std.testing.expect(!configNameHasSlashPrefix("ui")); var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("reqconfig /ui", try writeReqconfig("/ui", &buf)); const name = parseReqconfigName(tokenizeCard("reqconfig /ui")).?; try std.testing.expectEqualStrings("/ui", name); } test "sync §10.3 r21: server silently ignores reqconfig without Read, no error card" { try std.testing.expectEqual(ReqconfigDisposition.ignore, serverReqconfigDisposition(false)); try std.testing.expectEqual(ReqconfigDisposition.reply, serverReqconfigDisposition(true)); } test "sync §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/§10.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); // §10.3 r20 L2369-2370 — 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 command:")); } 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 (§8.3/§8.4 L692) 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 §10.3 r22: clients defer reqconfig until after clone round 0" { const Case = struct { op: Operation, round: u64, want: bool }; const cases = [_]Case{ .{ .op = .clone, .round = 0, .want = false }, .{ .op = .clone, .round = 1, .want = true }, .{ .op = .sync, .round = 0, .want = true }, .{ .op = .pull, .round = 3, .want = true }, }; for (cases) |c| { try std.testing.expectEqual(c.want, sendableAfterCloneRound0(c.op, c.round)); } } test "sync §10.3 r23: config GROUPNAME SIZE card then SIZE content bytes" { const c = try parseConfigCard(tokenizeCard("config /config 32")); try std.testing.expectEqualStrings("/config", c.groupname); try std.testing.expectEqual(@as(u32, 32), c.size); var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("config /config 32", try writeConfigCard("/config", 32, &buf)); const payload = try consumeConfigPayload("abc\n", 0, 3); try std.testing.expectEqualStrings("abc", payload.bytes); try std.testing.expectEqual(@as(usize, 4), payload.next); } test "sync §10.3 r24: scalar settings travel as /config, an encoding marker not a group id" { try std.testing.expectEqual(ConfigKind.scalar, configKind("/config")); try std.testing.expect(std.mem.eql(u8, config_scalar_marker, "/config")); } test "sync §10.3 r25: scalar payload is MTIME SP QUOTED-NAME SP value SP QUOTED-VALUE" { var buf: [64]u8 = undefined; const written = try writeScalarConfig(1720483200, "autosync", "on", &buf); try std.testing.expectEqualStrings("1720483200 'autosync' value 'on'", written); const parsed = try parseScalarConfig(written); try std.testing.expectEqual(@as(u64, 1720483200), parsed.mtime); try std.testing.expectEqualStrings("'autosync'", parsed.quoted_name); try std.testing.expectEqualStrings("'on'", parsed.quoted_value); } test "sync §10.3 r26: Fossil quote encoding wraps in ' and doubles internal '" { var qbuf: [16]u8 = undefined; try std.testing.expectEqualStrings("'it''s'", fossilQuote("it's", &qbuf)); var ubuf: [16]u8 = undefined; try std.testing.expectEqualStrings("it's", try fossilUnquote("'it''s'", &ubuf)); var rbuf: [16]u8 = undefined; const requoted = fossilQuote("a'b'c", &rbuf); var rubuf: [16]u8 = undefined; try std.testing.expectEqualStrings("a'b'c", try fossilUnquote(requoted, &rubuf)); var xbuf: [8]u8 = undefined; try std.testing.expectEqualStrings("'x'", fossilQuote("x", &xbuf)); } test "sync §10.3 r27: /config card name alone cannot identify the requested group" { // Two conceptually distinct requests both surface under the same // fixed marker: the byte carries no group information. try std.testing.expectEqual(ConfigKind.scalar, configKind(config_scalar_marker)); try std.testing.expect(std.mem.eql(u8, config_scalar_marker, "/config")); } test "sync §10.3 r28: table entries travel as /GROUPNAME identifying the table" { try std.testing.expectEqual(ConfigKind.table, configKind("/shun")); try std.testing.expectEqual(ConfigKind.table, configKind("/user")); try std.testing.expectEqual(ConfigKind.scalar, configKind("/config")); } test "sync §10.3 r29: a single trailing newline follows each config payload and is consumed" { const ok = try consumeConfigPayload("hi\nX", 0, 2); try std.testing.expectEqualStrings("hi", ok.bytes); try std.testing.expectEqual(@as(usize, 3), ok.next); try std.testing.expectError(ConfigFrameError.MissingNewline, consumeConfigPayload("hiZ", 0, 2)); } test "sync §10.3 r30: Admin may push a scalar config NAME SIZE card, same /config form" { var payload_buf: [64]u8 = undefined; const payload = try writeScalarConfig(1720483200, "autosync", "on", &payload_buf); var card_buf: [32]u8 = undefined; const card = try writeConfigCard("/config", @intCast(payload.len), &card_buf); try std.testing.expectEqualStrings("config /config 32", card); try std.testing.expectEqual(ConfigKind.scalar, configKind("/config")); } test "sync §10.3 r31: server requires Admin to push; else error card, not applied (rejection)" { try std.testing.expectEqual(ConfigPushDecision.reject, serverConfigPushDecision(false)); try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true)); try std.testing.expectEqualStrings("not authorized to push configuration", config_push_unauthorized_text); } test "sync §10.3 r32: pushed config card with negative SIZE is fatal (rejection)" { try std.testing.expectError(ConfigError.NegativeSize, parseConfigCard(tokenizeCard("config /config -1"))); } test "sync §10.3 r33: well-formed authorized push is applied; SIZE payload + newline consumed" { try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true)); const payload = try consumeConfigPayload("on\nNEXT", 0, 2); try std.testing.expectEqualStrings("on", payload.bytes); try std.testing.expectEqual(@as(usize, 3), payload.next); } test "sync §10.3 r34: only the scalar /config form is pushed, not table groups" { try std.testing.expectEqual(ConfigKind.scalar, configKind("/config")); try std.testing.expectEqual(ConfigKind.table, configKind("/shun")); } test "sync §10.3: quote/parse robustness negatives" { var buf: [16]u8 = undefined; try std.testing.expectError(QuoteError.Malformed, fossilUnquote("abc", &buf)); try std.testing.expectError(QuoteError.Malformed, fossilUnquote("'a'b'", &buf)); try std.testing.expectError(error.Malformed, parseScalarConfig("123 'n' notvalue 'v'")); try std.testing.expectError(ConfigError.Malformed, parseConfigCard(tokenizeCard("config /config x"))); try std.testing.expectError(ConfigError.Malformed, parseConfigCard(tokenizeCard("config /config 5000000000"))); } test "sync §10.4 r35: pragma ci-lock HASH CLIENT-ID" { const lock = parseCiLock(tokenizeCard("pragma ci-lock abcdef0123 checkout-42")).?; try std.testing.expectEqualStrings("abcdef0123", lock.hash); try std.testing.expectEqualStrings("checkout-42", lock.client_id); var buf: [48]u8 = undefined; try std.testing.expectEqualStrings("pragma ci-lock abcdef0123 checkout-42", try writeCiLock("abcdef0123", "checkout-42", &buf)); } test "sync §10.4 r36: lock held by a different client -> ci-lock-fail LOGIN MTIME (rejection)" { try std.testing.expectEqual(CiLockResult.failed, serverCiLockResult("alice", "bob")); try std.testing.expectEqual(CiLockResult.granted, serverCiLockResult("alice", "alice")); try std.testing.expectEqual(CiLockResult.granted, serverCiLockResult(null, "bob")); var buf: [48]u8 = undefined; const written = try writeCiLockFail("alice", 1720483200, &buf); try std.testing.expectEqualStrings("pragma ci-lock-fail alice 1720483200", written); const parsed = parseCiLockFail(tokenizeCard(written)).?; try std.testing.expectEqualStrings("alice", parsed.login); try std.testing.expectEqual(@as(u64, 1720483200), parsed.mtime); } test "sync §10.4 r37: recorded ci-lock-fault blocks only a fork-creating commit without an override" { // §10.4 L2548-2556: blocks iff a fault is recorded AND the commit // would fork AND no override was requested -- exhaustive 8-row // truth table, one row per combination. const Case = struct { fault: bool, fork: bool, override: bool, want: bool }; const cases = [_]Case{ .{ .fault = false, .fork = false, .override = false, .want = true }, .{ .fault = false, .fork = false, .override = true, .want = true }, .{ .fault = false, .fork = true, .override = false, .want = true }, .{ .fault = false, .fork = true, .override = true, .want = true }, .{ .fault = true, .fork = false, .override = false, .want = true }, .{ .fault = true, .fork = false, .override = true, .want = true }, .{ .fault = true, .fork = true, .override = false, .want = false }, // the single blocked row .{ .fault = true, .fork = true, .override = true, .want = true }, }; for (cases) |c| { try std.testing.expectEqual(c.want, commitMayProceedAfterCiLockFail(c.fault, c.fork, c.override)); } } test "sync §10.4 r38: pragma ci-unlock CLIENT-ID releases the lock" { const id = parseCiUnlock(tokenizeCard("pragma ci-unlock checkout-42")).?; try std.testing.expectEqualStrings("checkout-42", id); var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("pragma ci-unlock checkout-42", try writeCiUnlock("checkout-42", &buf)); } test "sync §10.4 r39: known-id clients send ci-unlock in later rounds when no lock pending" { const Case = struct { has_client_id: bool, lock_pending: bool, round: u64, want: bool }; const cases = [_]Case{ .{ .has_client_id = true, .lock_pending = false, .round = 3, .want = true }, .{ .has_client_id = true, .lock_pending = true, .round = 3, .want = false }, .{ .has_client_id = false, .lock_pending = false, .round = 3, .want = false }, .{ .has_client_id = true, .lock_pending = false, .round = 0, .want = false }, }; for (cases) |c| { try std.testing.expectEqual(c.want, clientSendsCiUnlock(c.has_client_id, c.lock_pending, c.round)); } } test "sync §10.5 r40: pragma send-private is client-to-server, arg-less" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("pragma send-private", try writeFlagPragma(.@"send-private", &buf)); try std.testing.expectEqual(PragmaDirection.client_to_server, pragmaDirection(.@"send-private")); } test "sync §10.5 r41: server enables private sync only with the Private (x) capability" { try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true)); try std.testing.expectEqual(SendPrivateDecision.reject, serverSendPrivateDecision(false)); } test "sync §10.5 r42: unauthorized send-private emits a non-reset error card (rejection)" { try std.testing.expectEqualStrings("not authorized to sync private content", send_private_unauthorized_text); var buf: [128]u8 = undefined; try std.testing.expectEqualStrings( "error not\\sauthorized\\sto\\ssync\\sprivate\\scontent", writeSignalCard(.err, send_private_unauthorized_text, &buf), ); try std.testing.expectEqual(ErrorReset.append, serverErrorReset(.unauthorized_send_private)); } test "sync §10.5 r43: send-private repeats every private round; the decision is idempotent" { try std.testing.expectEqual(PragmaSchedule.every_private_round, pragmaSchedule(.@"send-private").?); try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true)); try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true)); } test "sync §10.5 r44: pragma send-catalog requests every igot (full resync)" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("pragma send-catalog", try writeFlagPragma(.@"send-catalog", &buf)); } test "sync §10.5 r45: pragma req-clusters requests cluster igot cards" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("pragma req-clusters", try writeFlagPragma(.@"req-clusters", &buf)); } test "sync §10.5 r46: pragma req-links requests link-graph data" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("pragma req-links", try writeFlagPragma(.@"req-links", &buf)); } test "sync §10.5 r47: pragma client-url communicates the client's canonical URL" { var buf: [64]u8 = undefined; try std.testing.expectEqualStrings("pragma client-url http://x/y", writeClientUrl("http://x/y", &buf)); var write_buf: [64]u8 = undefined; const written = writeClientUrl("a b", &write_buf); try std.testing.expectEqualStrings("pragma client-url a\\sb", written); var decode_buf: [16]u8 = undefined; const parsed = parseClientUrl(tokenizeCard(written), &decode_buf).?; try std.testing.expectEqualStrings("a b", parsed); } test "sync §10.5 r48: client-url and req-links are sent once, before the round loop" { try std.testing.expectEqual(PragmaSchedule.once_pre_loop, pragmaSchedule(.@"client-url").?); try std.testing.expectEqual(PragmaSchedule.once_pre_loop, pragmaSchedule(.@"req-links").?); } test "sync §10.5 r49: pragma avoid-delta-manifests is server-to-client" { try std.testing.expectEqual(PragmaDirection.server_to_client, pragmaDirection(.@"avoid-delta-manifests")); var buf: [40]u8 = undefined; try std.testing.expectEqualStrings("pragma avoid-delta-manifests", try writeFlagPragma(.@"avoid-delta-manifests", &buf)); } test "sync §10.5: arg-less pragma set matches the ABNF" { const Case = struct { name: PragmaName, argless: bool }; const cases = [_]Case{ .{ .name = .@"send-private", .argless = true }, .{ .name = .@"send-catalog", .argless = true }, .{ .name = .@"req-links", .argless = true }, .{ .name = .@"req-clusters", .argless = true }, .{ .name = .@"avoid-delta-manifests", .argless = true }, .{ .name = .@"uv-push-ok", .argless = true }, .{ .name = .@"uv-pull-only", .argless = true }, .{ .name = .@"client-version", .argless = false }, .{ .name = .@"server-version", .argless = false }, .{ .name = .@"uv-hash", .argless = false }, .{ .name = .@"ci-lock", .argless = false }, .{ .name = .@"ci-lock-fail", .argless = false }, .{ .name = .@"ci-unlock", .argless = false }, .{ .name = .@"client-url", .argless = false }, }; try std.testing.expectEqual(@as(usize, 14), cases.len); for (cases) |c| { try std.testing.expectEqual(c.argless, pragmaIsArgless(c.name)); } } test "sync §11.1 r1: an error card is 'error TEXT' (fatal outside clone round 0)" { // L2604-2607. The "fatal except the first clone round" half is realized by // r4 (terminate elsewhere) and r10 (discard in round 0) below. var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("error abc", writeSignalCard(.err, "abc", &buf)); try std.testing.expectEqual(Operator.@"error", classifyCard("error abc").operator.?); } test "sync §11.1 r2: error TEXT is fossilize-encoded, spaces as \\s" { // L2609-2610. var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("error a\\sb", writeSignalCard(.err, "a b", &buf)); } test "sync §11.1 r3: a receiver defossilizes error TEXT before display/log" { // L2611-2612. var decode_buf: [8]u8 = undefined; const decoded = parseSignalText(tokenizeCard("error a\\sb"), &decode_buf).?; try std.testing.expectEqualStrings("a b", decoded); } test "sync §11.1 r4: an error card outside clone round 0 stops processing and terminates" { // L2614-2616. "Stop processing the response body" is realized as // terminating the sync loop; every non-round-0 state terminates. const states = [_]ClientState{ .init, .exchange, .terminated, .aborted }; for (states) |s| try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(s)); } test "sync §11.1 r5: no retry via alternative encoding or hash algorithm (rejection)" { // L2616-2617; A.2 "No SHA1 retry" L3219. The client's only dispositions are // discard/terminate -- no "retry" variant, so a retry is unrepresentable. const fields = @typeInfo(ClientErrorAction).@"enum".fields; try std.testing.expectEqual(@as(usize, 2), fields.len); try std.testing.expectEqualStrings("discard", fields[0].name); try std.testing.expectEqualStrings("terminate", fields[1].name); try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange)); } test "sync §11.1 r6/r8: reset sites discard prior output; append sites extend it" { const Case = struct { site: ErrorSite, want: ErrorReset }; const cases = [_]Case{ // r6 L2619-2623 -- fatal protocol-error sites reset prior partial output. .{ .site = .wrong_project, .want = .reset }, .{ .site = .auth_failure, .want = .reset }, .{ .site = .duplicate_login, .want = .reset }, .{ .site = .unknown_operator, .want = .reset }, // r8 L2626-2629 -- gate / send-private / private sites append to the // already-built response instead of resetting it. .{ .site = .peer_version_gate, .want = .append }, .{ .site = .unauthorized_send_private, .want = .append }, .{ .site = .private_card, .want = .append }, }; for (cases) |c| try std.testing.expectEqual(c.want, serverErrorReset(c.site)); } test "sync §11.1 r7: a reset does not promise an error-only response body" { // L2623-2626. The disposition is binary {reset,append}; there is no // "error_only" variant, so a reset governs only prior-output discard -- // the unauthorized-clone site still emits a push card after the reset and // before the error card. const fields = @typeInfo(ErrorReset).@"enum".fields; try std.testing.expectEqual(@as(usize, 2), fields.len); try std.testing.expectEqualStrings("reset", fields[0].name); try std.testing.expectEqualStrings("append", fields[1].name); try std.testing.expectEqual(ErrorReset.reset, serverErrorReset(.auth_failure)); } test "sync §11.1 r9: the error is signalled in the body, not an HTTP status" { // L2639-2640; A.2 header "HTTP 200" L3203. classifyCard detects the error // in the body; clientOnErrorCard takes no status input. try std.testing.expectEqual(Operator.@"error", classifyCard("error abc").operator.?); } test "sync §11.1 r10: the first clone round silently discards an error card" { // L2642-2644. try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0)); } test "sync §11.1 r11: the SHA3 peer-version gate text is exact" { // L2648-2650 (decoded text); encoded form per A.2 L3206. try std.testing.expectEqualStrings("Fossil version 2.0 or later required.", peer_version_error_text); var buf: [96]u8 = undefined; try std.testing.expectEqualStrings( "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.", writeSignalCard(.err, peer_version_error_text, &buf), ); } test "sync §11.1 App. A.2: SHA3 version-gate error card, discarded by a round-0 clone" { // First response-body line L3206; decoded text L3211. The 191-byte // Content-Length (L3204) is already pinned by the §7.6 App. A.2 test below, // so it is not re-asserted here. var buf: [96]u8 = undefined; try std.testing.expectEqualStrings( "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.", writeSignalCard(.err, peer_version_error_text, &buf), ); var decode_buf: [64]u8 = undefined; const line = "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired."; const decoded = parseSignalText(tokenizeCard(line), &decode_buf).?; try std.testing.expectEqualStrings("Fossil version 2.0 or later required.", decoded); // A round-0 clone client silently discards it (L3221-3227); no SHA1 retry // (L3219) -- the disposition enum has no retry variant. try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0)); } test "sync §11.2 r1: a message card is 'message TEXT', a non-fatal advisory" { // L2654-2656. var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("message hi", writeSignalCard(.message, "hi", &buf)); try std.testing.expectEqual(Operator.message, classifyCard("message hi").operator.?); } test "sync §11.2 r2: message TEXT is encoded identically to an error card" { // L2658. Same single-text-argument encoder: the tail after the operator // word is byte-identical between the two cards. var mbuf: [16]u8 = undefined; var ebuf: [16]u8 = undefined; const msg = writeSignalCard(.message, "a b", &mbuf); const err = writeSignalCard(.err, "a b", &ebuf); try std.testing.expectEqualStrings("message a\\sb", msg); try std.testing.expectEqualStrings(err["error ".len..], msg["message ".len..]); } test "sync §11.2 r1: a written message card tokenizes to exactly two tokens" { // §11.2 item preamble — "exactly two whitespace-separated tokens". TEXT is // fossilize-encoded, so any embedded SP/LF becomes an escape and never a // second separator: the card always splits into `message` + one argument, // never surplus, even for whitespace-laden text. var buf: [64]u8 = undefined; const texts = [_][]const u8{ "a b", "x\ny", "c\\d", "pull only " }; for (texts) |t| { const card = writeSignalCard(.message, t, &buf); const toks = tokenizeCard(card); try std.testing.expectEqual(@as(usize, 2), toks.len); try std.testing.expect(!toks.surplus); try std.testing.expectEqualStrings("message", toks.items[0]); } } test "sync §11.2 r2: message TEXT round-trips fossilize/defossilize" { // §11.2 item preamble — TEXT uses the error-card encoding; a receiver // defossilizes with the same scheme. parseSignalText(tokenize(write)) is // the identity on the original text. var enc: [64]u8 = undefined; var dec: [64]u8 = undefined; const texts = [_][]const u8{ "a b", "x\ny", "c\\d", "pull only for now" }; for (texts) |t| { const card = writeSignalCard(.message, t, &enc); const got = parseSignalText(tokenizeCard(card), &dec).?; try std.testing.expectEqualStrings(t, got); } } test "sync §11.2 r3: a message card never terminates the session" { // §11.2 item 2/3 — the client's dispositions are {pull_only_silent, // display_optional}; neither aborts or errors the exchange. const fields = @typeInfo(MessageAction).@"enum".fields; try std.testing.expectEqual(@as(usize, 2), fields.len); try std.testing.expectEqualStrings("pull_only_silent", fields[0].name); try std.testing.expectEqualStrings("display_optional", fields[1].name); try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard(true, "hello")); } test "sync §11.2 r7: pull only GLOB is case-sensitive with a fixed 'pull only ' prefix" { // §11.2 item 1 — "the comparison is case-sensitive; the literal ASCII // sequence pull only ... separated by a single SP and followed by a // mandatory trailing SP is the fixed prefix". Anything not carrying that // exact 10-byte prefix at offset 0 fails. const Case = struct { text: []const u8, want: bool }; const cases = [_]Case{ .{ .text = "pull only ", .want = true }, // exact prefix, empty suffix .{ .text = "Pull only ", .want = false }, // case-sensitive .{ .text = "PULL ONLY ", .want = false }, // case-sensitive .{ .text = "pull only ", .want = false }, // double SP != single-SP prefix .{ .text = "pull only", .want = false }, // no mandatory trailing SP .{ .text = "xpull only ", .want = false }, // prefix not at start }; for (cases) |c| try std.testing.expectEqual(c.want, matchesPullOnly(c.text)); } test "sync §11.2 r8: worked-example pull only payloads match; no whitespace trimming" { // §11.2 item 1 worked examples, verbatim: a bare "pull only " (trailing SP, // empty suffix) matches, as do "pull only for now" and // "pull only pushing disabled". "No trimming of surrounding whitespace is // performed", so a leading SP defeats the fixed-prefix match. const Case = struct { text: []const u8, want: bool }; const cases = [_]Case{ .{ .text = "pull only ", .want = true }, .{ .text = "pull only for now", .want = true }, .{ .text = "pull only pushing disabled", .want = true }, .{ .text = " pull only ", .want = false }, // leading SP, no trimming }; for (cases) |c| try std.testing.expectEqual(c.want, matchesPullOnly(c.text)); } test "sync §11.2 r4/r9/r14: clear only when push is live and the payload matches" { // §11.2 item 1 clears iff push intent is live AND payload matches pull // only * (r4); every other combination is item 2's advisory path — a // non-matching payload (r9) or a match while push is already clear (r14). const Case = struct { push_active: bool, text: []const u8, want: MessageAction }; const cases = [_]Case{ .{ .push_active = true, .text = "pull only ", .want = .pull_only_silent }, // r4 empty suffix .{ .push_active = true, .text = "pull only now", .want = .pull_only_silent }, // r4 suffix .{ .push_active = true, .text = "hello", .want = .display_optional }, // r9 non-match .{ .push_active = true, .text = "pull only", .want = .display_optional }, // r9 bare, no trailing SP .{ .push_active = false, .text = "pull only ", .want = .display_optional }, // r14 push already clear .{ .push_active = false, .text = "hello", .want = .display_optional }, // r9 non-match, pull }; for (cases) |c| try std.testing.expectEqual(c.want, clientOnMessageCard(c.push_active, c.text)); } test "sync §3.6: server emits 'cookie TEXT', TEXT opaque" { var buf: [24]u8 = undefined; try std.testing.expectEqualStrings("cookie s3cr3t", writeCookieCard("s3cr3t", &buf)); const c = classifyCard("cookie s3cr3t"); try std.testing.expectEqual(Operator.cookie, c.operator.?); try std.testing.expectEqual(Disposition.dispatch, c.disposition); } test "sync §3.6/§5.2: cookie TEXT is fossilize-encoded" { var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("cookie a\\sb", writeCookieCard("a b", &buf)); } test "sync §3.6: client stores cookie, replacing any previously held" { var store: CookieStore = .{}; store.store("v1"); store.store("v2"); try std.testing.expectEqualStrings("v2", store.value().?); var decode_buf: [8]u8 = undefined; const decoded = parseSignalText(tokenizeCard("cookie v2"), &decode_buf).?; store.store(decoded); try std.testing.expectEqualStrings("v2", store.value().?); } test "sync §3.6: client returns the stored cookie in a later request" { var store: CookieStore = .{}; store.store("a b"); var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("cookie a\\sb", writeCookieCard(store.value().?, &buf)); } test "sync §3.6: server does not depend on a cookie card" { const c = classifyCard("cookie x"); try std.testing.expectEqual(Operator.cookie, c.operator.?); try std.testing.expectEqual(Disposition.dispatch, c.disposition); try std.testing.expect(!isFatal(c.disposition, .server)); } test "sync §3.6: server tolerates a cookie from a different server" { const written = "cookie a\\sb"; const c = classifyCard(written); try std.testing.expectEqual(Disposition.dispatch, c.disposition); try std.testing.expect(!isFatal(c.disposition, .server)); var decode_buf: [8]u8 = undefined; const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?; try std.testing.expectEqualStrings("a b", decoded); } test "sync §3.6: server tolerates more than one cookie card in a request" { const lines = [_][]const u8{ "cookie one", "cookie two", "cookie three" }; for (lines) |line| { const c = classifyCard(line); try std.testing.expectEqual(Disposition.dispatch, c.disposition); try std.testing.expect(!isFatal(c.disposition, .server)); } } test "sync §3.6: a client that never returns a cookie is conformant" { const store: CookieStore = .{}; try std.testing.expect(store.value() == null); } test "sync §7.6/§12.4: SHA3-256 artifact refused to peer version < 20000 (rejection)" { const sha3_hex = "a" ** 64; try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, sha3_hex)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, sha3_hex)); } test "sync §12.4 / §7.6 r3: no silent downgrade to SHA1" { const sha3_hex = "a" ** 64; const sha1_hex = "a" ** 40; try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, sha3_hex)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, sha1_hex)); } test "sync §12.4: gate has no truncate/re-identify path (rejection)" { try std.testing.expectEqual(@as(usize, 2), @typeInfo(GateDecision).@"enum".fields.len); const sha3_hex = "a" ** 64; const sha1_hex = "a" ** 40; const not_hex_64 = "z" ** 64; try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, sha3_hex)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, sha3_hex)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, sha1_hex)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, sha1_hex)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, not_hex_64)); } test "sync §10.1: gate is pure — unrecorded-peer default is the caller's, side-dependent" { const sha3_hex = "a" ** 64; // Server side: before any client-version pragma, VERSION is treated // as 0 (L2317-2319) -> the caller passes 0 -> refuse. try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(0, sha3_hex)); // Client side: before any server-version pragma, VERSION is treated // as the client's own release integer (L2325-2327); a SHA3-capable // client's own integer is >= 20000 -> the caller passes that -> emit. const own_release: u32 = 20500; try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(own_release, sha3_hex)); } test "sync §7.6 r6: the one gate governs the file, cfile, and uvfile 64-hex forms" { const sites = [_][]const u8{ "1" ** 64, // stands for a file-card HASH "2" ** 64, // stands for a cfile-card HASH "3" ** 64, // stands for a uvfile-card HASH field }; const sha1_hex = "a" ** 40; for (sites) |hash| { try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(19999, hash)); try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(20000, hash)); } try std.testing.expectEqual(GateDecision.emit, sha3PeerVersionGate(19999, sha1_hex)); } test "sync §7.6 App. A.2: gate-fire response body is 191 bytes; zit's error card is its first line" { const body = "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.\n" ++ "clone_seqno 0\n" ++ "push 34302acad64fe2397bd907704dec457899e2f9a3 c85e3efddf993e86e8325d08672ea94bdba6c2c4\n" ++ "# timestamp 2026-07-02T18:26:49 errors 0\n"; try std.testing.expectEqual(@as(usize, 191), body.len); var buf: [96]u8 = undefined; const err_card = writeSignalCard(.err, peer_version_error_text, &buf); const first_line_end = std.mem.indexOfScalar(u8, body, '\n').?; try std.testing.expectEqualStrings(err_card, body[0..first_line_end]); } test "sync §3.7 r1: another round iff at least one of (a)-(f) holds" { const base = RoundObservation{ .operation = .pull }; try std.testing.expect(!continueExchange(base)); var a = base; a.versioned_content_delivered = true; a.phantom_table_nonempty = true; try std.testing.expect(continueExchange(a)); var b = base; b.content_queued = true; try std.testing.expect(continueExchange(b)); var c = base; c.private_discovery_pending = true; try std.testing.expect(continueExchange(c)); var d = base; d.uv_send_table_nonempty = true; d.uv_upload_enabled = true; try std.testing.expect(continueExchange(d)); const e = RoundObservation{ .operation = .clone, .first_clone_round = true }; try std.testing.expect(continueExchange(e)); var f = base; f.config_exchange_pending = true; try std.testing.expect(continueExchange(f)); } test "sync §3.7 r2: versioned progress with non-empty phantom table continues (a)" { const Case = struct { delivered: bool = false, created: bool = false, phantom_nonempty: bool, want: bool }; const cases = [_]Case{ .{ .delivered = true, .phantom_nonempty = true, .want = true }, .{ .created = true, .phantom_nonempty = true, .want = true }, .{ .delivered = true, .phantom_nonempty = false, .want = false }, // guard, L735-736 .{ .phantom_nonempty = true, .want = false }, // nothing progressed }; for (cases) |tc| { const obs = RoundObservation{ .operation = .pull, .versioned_content_delivered = tc.delivered, .phantom_created = tc.created, .phantom_table_nonempty = tc.phantom_nonempty, }; try std.testing.expectEqual(tc.want, continueExchange(obs)); } } test "sync §3.7 r3: non-empty send queue continues (b)" { var obs = RoundObservation{ .operation = .push }; obs.content_queued = true; try std.testing.expect(continueExchange(obs)); } test "sync §3.7 r4: private-content discovery round continues (c)" { var obs = RoundObservation{ .operation = .sync }; obs.private_discovery_pending = true; try std.testing.expect(continueExchange(obs)); } test "sync §3.7 r5: unversioned send table + upload-enabled continues (d)(1)" { const Case = struct { table_nonempty: bool, upload_enabled: bool, want: bool }; const cases = [_]Case{ .{ .table_nonempty = true, .upload_enabled = true, .want = true }, .{ .table_nonempty = true, .upload_enabled = false, .want = false }, .{ .table_nonempty = false, .upload_enabled = true, .want = false }, }; for (cases) |tc| { const obs = RoundObservation{ .operation = .push, .uv_send_table_nonempty = tc.table_nonempty, .uv_upload_enabled = tc.upload_enabled, }; try std.testing.expectEqual(tc.want, continueExchange(obs)); } } test "sync §3.7 r6: uvgimme with uvfile or round<3 continues (d)(2)" { const Case = struct { queued: bool, uvfile: bool, round: u64, want: bool }; const cases = [_]Case{ .{ .queued = true, .uvfile = true, .round = 5, .want = true }, .{ .queued = true, .uvfile = false, .round = 2, .want = true }, .{ .queued = true, .uvfile = false, .round = 3, .want = false }, .{ .queued = false, .uvfile = false, .round = 0, .want = false }, }; for (cases) |tc| { const obs = RoundObservation{ .operation = .push, .uvgimme_queued = tc.queued, .uvfile_received = tc.uvfile, .session_round = tc.round, }; try std.testing.expectEqual(tc.want, continueExchange(obs)); } } test "sync §3.7 r7: modern-clone continuation conditions (e)" { const Case = struct { op: Operation = .clone, first: bool = false, received: bool = false, seqno: i64 = 0, grew: bool = false, want: bool, }; const cases = [_]Case{ .{ .first = true, .want = true }, .{ .received = true, .want = true }, .{ .seqno = 5, .grew = true, .want = true }, .{ .seqno = 5, .grew = false, .want = false }, // non-advancing peer, L1915-1924 .{ .seqno = 0, .grew = true, .want = false }, .{ .op = .pull, .received = true, .seqno = 5, .grew = true, .want = false }, }; for (cases) |tc| { const obs = RoundObservation{ .operation = tc.op, .first_clone_round = tc.first, .artifact_received_this_round = tc.received, .clone_seqno_next = tc.seqno, .artifact_count_grew = tc.grew, }; try std.testing.expectEqual(tc.want, continueExchange(obs)); } } test "sync §3.7 r8: pending configuration exchange continues (f)" { var obs = RoundObservation{ .operation = .sync }; obs.config_exchange_pending = true; try std.testing.expect(continueExchange(obs)); } test "sync §3.7 r9: phantoms remaining alone do not continue (rejection)" { const obs = RoundObservation{ .operation = .pull, .phantom_table_nonempty = true }; try std.testing.expect(!continueExchange(obs)); } test "sync §3.7 r10: no pending work in either direction stops" { const obs = RoundObservation{ .operation = .sync }; try std.testing.expect(!continueExchange(obs)); try std.testing.expectEqual(ClientState.terminated, transition(.exchange, .round_terminates)); } test "sync §3.4: the modern-clone operation card is clone 3 SEQNO [sync-3-4-r1]" { // L684 — "a normal sequence of sync rounds whose operation card is // clone 3 SEQNO." SEQNO 42 is arbitrary non-negative, distinct from // §8.1's worked `clone 3 7`. var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("clone 3 42", try writeCloneCard(42, &buf)); // clone is an ordinary dispatchable operator, not a special-cased path. const cc = classifyCard("clone 3 42"); try std.testing.expectEqual(Operator.clone, cc.operator.?); try std.testing.expectEqual(Disposition.dispatch, cc.disposition); } test "sync §3.4: clone replies ride the operation-agnostic dispatch loop [sync-3-4-r2]" { // L688-689 — clone replies are processed by the "same card dispatch // loop" as other rounds. Sameness is structural: the dispatch // primitives take no Operation parameter, so no round can branch the // loop by operation; adding such a parameter would trip this guard. inline for (.{ classifyCard, isFatal, replyBodyStep, readLine }) |f| { inline for (@typeInfo(@TypeOf(f)).@"fn".params) |p| { if (p.type) |t| try std.testing.expect(t != Operation); } } // Runtime drive: a mixed clone-round reply body walks that one loop. const body = "igot H1\npragma send-catalog\ngimme H2\n"; const want = [_]Operator{ .igot, .pragma, .gimme }; var offset: usize = 0; for (want) |op| { try std.testing.expectEqual(ReplyBodyStep.card_ready, replyBodyStep(body, offset)); const line = try readLine(body, offset); const cc = classifyCard(line.bytes); try std.testing.expectEqual(op, cc.operator.?); try std.testing.expectEqual(Disposition.dispatch, cc.disposition); try std.testing.expect(!isFatal(cc.disposition, .client)); offset = line.next; } try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, offset)); } test "sync §3.4: clone replies use the same termination rule as other rounds [sync-3-4-r3]" { // L688-689 ("same ... termination rule") + §3.7 (a)-(f): one function // decides continuation for all four operations. Shared condition (f) // (L790-792, a reqconfig deferred during clone or otherwise required) // applies uniformly; clone-specific (e) (L765-769) lives inside it. for ([_]Operation{ .pull, .push, .sync, .clone }) |op| { try std.testing.expect(!continueExchange(.{ .operation = op })); try std.testing.expect(continueExchange(.{ .operation = op, .config_exchange_pending = true })); } try std.testing.expect(continueExchange(.{ .operation = .clone, .first_clone_round = true })); } test "sync §3.8 r11: the client occupies exactly five named states" { const fields = @typeInfo(ClientState).@"enum".fields; try std.testing.expectEqual(@as(usize, 5), fields.len); const want = [_][]const u8{ "init", "clone_round0", "exchange", "terminated", "aborted" }; for (want) |name| { var found = false; inline for (fields) |f| { if (std.mem.eql(u8, f.name, name)) found = true; } try std.testing.expect(found); } } test "sync §3.8 r12: INIT + modern clone -> CLONE_ROUND0" { try std.testing.expectEqual(ClientState.clone_round0, initialState(.clone)); } test "sync §3.8 r13: INIT + push/pull/sync -> EXCHANGE" { const ops = [_]Operation{ .push, .pull, .sync }; for (ops) |op| try std.testing.expectEqual(ClientState.exchange, initialState(op)); } test "sync §3.8 r14: CLONE_ROUND0 reply processed -> EXCHANGE, error discarded" { try std.testing.expectEqual(ClientState.exchange, transition(.clone_round0, .clone_reply_processed)); try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0)); } test "sync §3.8 r15: CLONE_ROUND0 transport/redirect/neg-seqno -> ABORTED" { try std.testing.expectEqual(ClientState.aborted, transition(.clone_round0, .clone_fatal)); try std.testing.expect(cloneSeqnoIsFatal(-1)); } test "sync §3.8 r16: EXCHANGE + a 3.7 condition -> EXCHANGE" { try std.testing.expectEqual(ClientState.exchange, transition(.exchange, .round_continues)); } test "sync §3.8 r17: EXCHANGE + none of 3.7 -> TERMINATED" { try std.testing.expectEqual(ClientState.terminated, transition(.exchange, .round_terminates)); } test "sync §3.8 r18: EXCHANGE + fatal protocol error -> ABORTED" { try std.testing.expectEqual(ClientState.aborted, transition(.exchange, .fatal_error)); } test "sync §3.8 r19: nine fatal causes abort EXCHANGE (rejection)" { const causes = [_]FatalCause{ .error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal, .unknown_operator, .malformed_content, .over_length_line, .redirect_limit, .clone_seqno_negative, }; try std.testing.expectEqual(@as(usize, 9), causes.len); for (causes) |c| try std.testing.expect(causeIsFatal(.exchange, c)); } test "sync §3.8 r19: CLONE_ROUND0 tolerates error-card-delivered causes, aborts client-detected ones (rejection)" { const tolerated = [_]FatalCause{ .error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal }; for (tolerated) |c| try std.testing.expect(!causeIsFatal(.clone_round0, c)); const fatal = [_]FatalCause{ .unknown_operator, .malformed_content, .over_length_line, .redirect_limit, .clone_seqno_negative, }; for (fatal) |c| try std.testing.expect(causeIsFatal(.clone_round0, c)); try std.testing.expectEqual(@as(usize, 9), tolerated.len + fatal.len); try std.testing.expect(cloneSeqnoIsFatal(-1)); try std.testing.expect(!cloneSeqnoIsFatal(0)); try std.testing.expect(!cloneSeqnoIsFatal(5)); } test "sync §3.9 r1: an unknown non-pragma card operator is a fatal reject" { // §3.9 L983-985 — client-detected, so fatal in every state, round 0 // included: it never arrives as a server error card. try std.testing.expect(causeIsFatal(.exchange, .unknown_operator)); try std.testing.expect(causeIsFatal(.clone_round0, .unknown_operator)); const c = classifyCard("frobnicate x"); try std.testing.expectEqual(Disposition.unknown_operator, c.disposition); try std.testing.expect(isFatal(c.disposition, .client)); try std.testing.expect(isFatal(c.disposition, .server)); } test "sync §3.9 r2: a malformed content card is a fatal reject" { // §3.9 L983-985 "and a malformed content card (Section 7)" — a // content-bearing operator carrying surplus tokens (§5 L234-235). try std.testing.expect(causeIsFatal(.exchange, .malformed_content)); try std.testing.expect(causeIsFatal(.clone_round0, .malformed_content)); const c = classifyCard("file a b c d e f g"); try std.testing.expectEqual(Disposition.malformed_content, c.disposition); try std.testing.expect(isFatal(c.disposition, .server)); } test "sync §3.9 r3: the liberality set is exactly the four named points, strict complement fatal" { // §3.9 L990-999 — liberal ∪ strict partitions the posture; the four // named points (L992-998) each tolerate, every other malformed input // aborts (L998-999). Each case asserts its backing mechanism tolerant. // (1) unknown pragma name is ignored (§10). const pragma_tokens = tokenizeCard("pragma no-such"); try std.testing.expect(classifyPragma(pragma_tokens).name == null); try std.testing.expectEqual(Disposition.dispatch, classifyCard("pragma no-such").disposition); // (2) runs of Fossil whitespace between tokens accepted (§5). const spaced = tokenizeCard("push a\t\tb"); const canonical = tokenizeCard("push a b"); try std.testing.expectEqual(canonical.len, spaced.len); for (0..canonical.len) |i| try std.testing.expectEqualStrings(canonical.items[i], spaced.items[i]); // (3) a cookie card from an unexpected server is tolerated (§3.6). const cookie = classifyCard("cookie a\\sb"); try std.testing.expectEqual(Disposition.dispatch, cookie.disposition); try std.testing.expect(!isFatal(cookie.disposition, .server)); // (4) an error card in the first clone round is discarded (§11.1). try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0)); // Strict complement — unknown operator and malformed content abort on // either side, and no fatal cause names a cookie or a pragma. try std.testing.expect(isFatal(.unknown_operator, .server)); try std.testing.expect(isFatal(.malformed_content, .server)); inline for (@typeInfo(FatalCause).@"enum".fields) |f| { try std.testing.expect(!std.mem.eql(u8, f.name, "cookie")); try std.testing.expect(!std.mem.eql(u8, f.name, "pragma")); } } fn scanCanonical(bytes: []const u8) !void { try std.testing.expect(bytes.len > 0); try std.testing.expect(!isFossilWhitespace(bytes[0])); try std.testing.expect(!isFossilWhitespace(bytes[bytes.len - 1])); for (bytes, 0..) |b, i| { if (!isFossilWhitespace(b)) continue; try std.testing.expectEqual(@as(u8, ' '), b); // separator is one space try std.testing.expect(i + 1 < bytes.len and !isFossilWhitespace(bytes[i + 1])); } try std.testing.expect(!tokenizeCard(bytes).surplus); } test "sync §3.9 r4: senders emit the canonical single-space form" { // §3.9 L994-995 — every card writer emits the form receivers rely on: // single-space separators, no surrounding or doubled whitespace. var buf: [128]u8 = undefined; try scanCanonical(try writeCloneCard(1, &buf)); try scanCanonical(try writePushPullCard(.push, "SC", "PC", &buf)); try scanCanonical(try writePushPullCard(.pull, "SC", "PC", &buf)); try scanCanonical(writeSignalCard(.err, "abc", &buf)); try scanCanonical(writeSignalCard(.message, "hi", &buf)); try scanCanonical(writeCookieCard("tok", &buf)); try scanCanonical(try writeClientVersion(.{ .version = 20000 }, &buf)); } test "sync §3.9 r4: a receiver does not preserve surplus trailing tokens" { // §3.9 L995-996 — past the six-slot window the 7th+ token is dropped // and flags surplus; surplus is non-dispatch on both branches: a // content card is .malformed_content (§5 L234-235), a simple card is // .bad_command (§5 L235 else). const t = tokenizeCard("push a b c d e f g"); try std.testing.expectEqual(@as(usize, max_tokens), t.len); try std.testing.expect(t.surplus); try std.testing.expectEqual(Disposition.malformed_content, classifyCard("file a b c d e f g").disposition); try std.testing.expectEqual(Disposition.bad_command, classifyCard("push a b c d e f g").disposition); } test "sync §3.9 r5: a receiver accepts Fossil-whitespace runs between tokens" { // §3.9 L992-994 — the compatibility extension: multi-byte separators // tokenize identically to the canonical single-space form. const spaced = tokenizeCard("push a\t\tb"); const canonical = tokenizeCard("push a b"); try std.testing.expectEqual(canonical.len, spaced.len); for (0..canonical.len) |i| try std.testing.expectEqualStrings(canonical.items[i], spaced.items[i]); } test "sync §3.9 r6: an unexpected-server cookie card is tolerated" { // §3.9 L996-997 (§3.6) — a cookie card dispatches, is never fatal, and // no fatal cause names a cookie. const c = classifyCard("cookie a\\sb"); try std.testing.expectEqual(Disposition.dispatch, c.disposition); try std.testing.expect(!isFatal(c.disposition, .server)); inline for (@typeInfo(FatalCause).@"enum".fields) |f| { try std.testing.expect(!std.mem.eql(u8, f.name, "cookie")); } } test "sync §3.9 r7: an error card in the first clone round is discarded" { // §3.9 L997-998 (§11.1) — round 0 discards, every other state terminates. try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0)); try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange)); } test "sync §3.9 r8: outside the four liberal points a receiver applies a rule or aborts" { // §3.9 L998-999 — an input that is none of the four liberal points and // not a valid card aborts; there is no tolerant fall-through. const c = classifyCard("frobnicate"); try std.testing.expectEqual(Disposition.unknown_operator, c.disposition); try std.testing.expect(isFatal(c.disposition, .server)); } test "sync §3.10 r1: the request-order restatement adds no constraint checkRequestOrder lacks" { // §3.10 L1016-1018 — §3.10 restates §3.2 and defers to it: a Table-4 // clone request is well-ordered; a §3.2 violation still yields the §3.2 // outcome, so §3.10 introduces no new order rule. const ok = [_]RequestCard{ .client_version, .content_request }; try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&ok)); const bad = [_]RequestCard{ .content_request, .client_version }; try std.testing.expectEqual(RequestOrder.version_after_content_request, checkRequestOrder(&bad)); } test "sync §3.10 r2: the canonical Table-4 write order projects to a well-ordered request" { // §3.10 L1020-1054 — request rows in write order: pragma client-version; // operation (clone → content_request); login; gimme (→ content_request); // file/cfile; igot; reqconfig/config/uvfile; cookie. The projection is // well-ordered and client-version (index 0) precedes every // content_request (L1026-1027). const rows = [_]RequestCard{ .client_version, .content_request, .other, .content_request, .other, .other, .other, .other, }; try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&rows)); try std.testing.expectEqual(RequestCard.client_version, rows[0]); for (rows, 0..) |card, i| { if (card == .content_request) try std.testing.expect(i > 0); } } test "sync §3.10 r3: every request body ends with a random-bytes comment line, not the timestamp subform" { // §3.10 L1052-1053 (Table 4) + §5.3 L1418-1422 — the request's closing // comment carries implementation random bytes; the timestamp/errors // subform (§5.3 L1389) is the reply's. A representative nonce line is a // comment but not a timestamp comment. try std.testing.expect(isCommentLine("# 9c1f4a7e2b3d")); try std.testing.expectEqual(@as(?TimestampComment, null), parseTimestampComment("# 9c1f4a7e2b3d")); } test "sync §3.10 r7: the login-card row defers to §6 presence and §8.3 round-0 suppression" { // §3.10 L1032-1034 — §6 owns login presence; the "unless clone round 0" // branch is §8.3's sendableAfterCloneRound0; the "unless cookie mode" // branch is §3.6 (a stored cookie replaces the login card). §3.10 adds // no new login rule. const line = "login privuser ee71329ef84fb77b428c6f626ad0103a426e0652 c3ee0be781c9ebcfc6e24756cbb75e6319ca255b"; try std.testing.expect(parseLoginCard(tokenizeCard(line)) != null); try std.testing.expect(!sendableAfterCloneRound0(.clone, 0)); try std.testing.expect(sendableAfterCloneRound0(.clone, 1)); try std.testing.expect(sendableAfterCloneRound0(.pull, 0)); } test "sync §3.10 r4: the reply-card membership set resolves to known operators" { // §3.10 L1058-1103 (Table 5) — every named reply card resolves to a // catalog operator; the closing comment is a comment line. const ops = [_][]const u8{ "pragma", "push", "pull", "igot", "file", "cfile", "gimme", "uvfile", "uvigot", "clone_seqno", "message", "error", }; for (ops) |op| try std.testing.expect(parseOperator(op) != null); try std.testing.expect(isCommentLine("# timestamp 2026-07-02T18:31:13 errors 0")); } test "sync §3.10 r4: the reply body is an unordered applicable combination, not a positional sequence" { // §3.10 L1058 "any applicable combination" — each reply card classifies // to .dispatch independent of position; reversing the body changes no // card's disposition. There is deliberately no checkReplyOrder analogue // to the request write-order authority. const reply = [_][]const u8{ "pull SC PC", "igot h 1", "clone_seqno 5", "message ok" }; for (reply) |line| try std.testing.expectEqual(Disposition.dispatch, classifyCard(line).disposition); var i: usize = reply.len; while (i > 0) { i -= 1; try std.testing.expectEqual(Disposition.dispatch, classifyCard(reply[i]).disposition); } } test "sync §3.10 r5: every reply body ends with a timestamp/error-count comment line" { // §3.10 L1101-1103 + §5.3 L1389 — the reply's closing comment is the // `# timestamp DATETIME errors N` subform; DATETIME is the 19-byte fixed // form YYYY-MM-DDTHH:MM:SS (§5.3 L1391). const parsed = parseTimestampComment("# timestamp 2026-07-02T18:31:13 errors 0").?; try std.testing.expectEqual(@as(u64, 0), parsed.errors); try std.testing.expectEqualStrings("2026-07-02T18:31:13", parsed.datetime); try std.testing.expectEqual(@as(usize, 19), parsed.datetime.len); } test "sync §3.10 r6: a reply push/pull advertisement is recorded but does not by itself select the operation" { // §3.10 L1076-1081 — the push/pull card is a pure record of the server's // identifiers; only a separate rule such as `message pull only *` // (→ .pull_only_silent) changes the selected operation. const rec = parsePushPull(tokenizeCard("pull SC PC")).?; try std.testing.expectEqualStrings("SC", rec.servercode); try std.testing.expectEqualStrings("PC", rec.projectcode); try std.testing.expectEqual(MessageAction.pull_only_silent, clientOnMessageCard(true, "pull only *")); try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard(true, "pull SC PC")); } const test_hash_a = "a" ** 40; const test_hash_b = "b" ** 40; test "sync §3.1 r20: per-round tables reset at round boundaries" { var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerHas(test_hash_a); try rt.recordPeerLacks(test_hash_b); rt.reset(); try std.testing.expectEqual(@as(usize, 0), rt.peer_has.count()); try std.testing.expectEqual(@as(usize, 0), rt.peer_lacks.count()); } test "sync §3.1 r21: per-round tables carry no state across rounds" { var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerHas(test_hash_a); try std.testing.expect(rt.peerHas(test_hash_a)); rt.reset(); try std.testing.expect(!rt.peerHas(test_hash_a)); } test "sync §3.3 r26: peer-has records hashes the peer announced or sent" { var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerHas(test_hash_a); try std.testing.expect(rt.peerHas(test_hash_a)); try std.testing.expect(!rt.peerHas(test_hash_b)); } test "sync §3.3 r27: peer-lacks records hashes the peer requested with gimme" { var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerLacks(test_hash_a); try std.testing.expect(rt.peerLacks(test_hash_a)); try std.testing.expect(!rt.peerLacks(test_hash_b)); } fn roundTablesNoLeak(gpa: std.mem.Allocator) !void { var rt = RoundTables.init(gpa); defer rt.deinit(); try rt.recordPeerHas(test_hash_a); try rt.recordPeerLacks(test_hash_b); // Repeat record of an already-held key must not leak the fresh dupe. try rt.recordPeerHas(test_hash_a); rt.reset(); } test "sync §3.3: round tables record* under allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, roundTablesNoLeak, .{}); } // §A.1 L4108-4109 — the two hashes the server gimmes because the client holds // them (the client announced igot for both, L4099-4100). The manifest is the // 272-byte checkin (L4123); alpha.txt is the 26-byte "alpha content from // client\n" (L4131-4132, L4153). const a1_manifest_hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; const a1_send_hash = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15"; // §A.1 L4101 — the third artifact the client already holds (shared by clone), // and L4097-4098 the two repository identifiers carried in the operation cards. const a1_held_hash = "c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57"; const a1_servercode = "da3e69ab0655a7dd2aba2520b2d3302a97c85787"; const a1_projectcode = "aa87ea41b0bb05ba528f7a3bdba700d7e23d6318"; test "sync §3.7(b)/§3.3: a gimme for held content exposes the hash in the send queue" { // §3.2 L761-766 — a push/sync client that holds a gimme'd hash queues it // in the per-session outbound send buffer (§3.1 L605-608). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const rid = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n"); try std.testing.expect(obs.content_queued); try std.testing.expectEqual(@as(usize, 1), client.send_queue.count()); try std.testing.expect(client.send_queue.contains(rid)); } test "sync §3.3 L855-856: a gimme for unheld content queues no send" { // The receiver does not hold HASH → it MUST silently skip: peer-lacks is // still recorded (L848), but the send buffer stays empty. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n"); try std.testing.expect(!obs.content_queued); try std.testing.expectEqual(@as(usize, 0), client.send_queue.count()); // Negative space (§3.3 L804-813 — the phantom set is closed; only igot and // dangling deltas may extend it): an unheld gimme resolves via lookup, not // intern, so it mints no phantom — not in the client's set, not in obs, and // no entry appears in the store for the gimme'd hash. try std.testing.expect(!obs.phantom_created); try std.testing.expect(!obs.phantom_table_nonempty); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(ms.store().lookup(a1_send_hash) == null); } test "sync §3.3 L853/§3.2 L763: a gimme for held content on a receive-only op is not queued" { // pull and clone are receive-only: the receiver holds HASH but the // operation direction does not permit sending it — no content, no queue. const ops = [_]Operation{ .pull, .clone }; for (ops) |op| { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = op, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n"); try std.testing.expect(!obs.content_queued); try std.testing.expectEqual(@as(usize, 0), client.send_queue.count()); } } test "sync §3.3 L849-853: clientSendsContent is true only for push and sync" { const Case = struct { op: Operation, want: bool }; const cases = [_]Case{ .{ .op = .pull, .want = false }, .{ .op = .push, .want = true }, .{ .op = .sync, .want = true }, .{ .op = .clone, .want = false }, }; for (cases) |tc| try std.testing.expectEqual(tc.want, clientSendsContent(tc.op)); } test "sync §3.7(b) L939-945: content_queued reflects sends appended this round, not the persistent buffer" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // Round 1: gimme held content → one send appended this round. const r1 = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\n"); try std.testing.expect(r1.content_queued); try std.testing.expectEqual(@as(usize, 1), client.send_queue.count()); // Round 2: a gimme-free reply appends nothing this round; the per-round // flag is false, yet the per-session buffer persists (§3.1 L605-608). const r2 = try client.consumeReply(std.testing.allocator, ms.store(), "# timestamp 2026-07-02T18:31:13 errors 0\n"); try std.testing.expect(!r2.content_queued); try std.testing.expectEqual(@as(usize, 1), client.send_queue.count()); } test "sync §3.7(b)/§3.1 L605-608: drainSendQueue empties the per-session buffer and returns its rids" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const rid1 = try ms.store().put(a1_send_hash, "alpha content from client\n"); const rid2 = try ms.store().put(a1_manifest_hash, "manifest payload\n"); var client = Client{ .operation = .push, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), "gimme " ++ a1_send_hash ++ "\ngimme " ++ a1_manifest_hash ++ "\n"); try std.testing.expectEqual(@as(usize, 2), client.send_queue.count()); var out: [4]repostate.Rid = undefined; const n = client.drainSendQueue(&out); try std.testing.expectEqual(@as(usize, 2), n); try std.testing.expectEqual(@as(usize, 0), client.send_queue.count()); // Order-agnostic membership: both queued rids come back. var seen1 = false; var seen2 = false; for (out[0..n]) |r| { if (r == rid1) seen1 = true; if (r == rid2) seen2 = true; } try std.testing.expect(seen1 and seen2); } test "sync §3.7(b)/§A.1 L4131-4132: a queued gimme uploads its artifact end-to-end" { // §A.1 — the client holds alpha.txt (b1c1b05f…, "alpha content from // client\n", 26 bytes, L4153); on a push the server gimmes it (L4109), the // client queues it, then uploads it as the round-2 file card (L4131-4132). // The upload is modelled as a store-to-store transfer of the drained rid. const payload = "alpha content from client\n"; try std.testing.expectEqual(@as(usize, 26), payload.len); var client_store = repostate.MemStore.init(std.testing.allocator); defer client_store.deinit(); _ = try client_store.store().put(a1_send_hash, payload); var server_store = repostate.MemStore.init(std.testing.allocator); defer server_store.deinit(); var client = Client{ .operation = .push, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, client_store.store(), "gimme " ++ a1_send_hash ++ "\n"); var out: [1]repostate.Rid = undefined; const n = client.drainSendQueue(&out); try std.testing.expectEqual(@as(usize, 1), n); // Emit the file card: read the drained rid's hash + content and store it // at the server, exactly the bytes the round-2 file card carries. const rid = out[0]; const hash = client_store.store().hashOf(rid); const content = client_store.store().content(rid).?; _ = try server_store.store().put(hash, content); const server_rid = server_store.store().lookup(a1_send_hash).?; try std.testing.expectEqualStrings(payload, server_store.store().content(server_rid).?); } /// §A preamble L4073-4075 — card order within a message is non-normative, so a /// request is asserted by card-SET membership: `line` must appear as a complete /// LF-framed line of `body`. (Sufficient for the file-card-free requests below.) fn expectHasLine(body: []const u8, line: []const u8) !void { var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |l| { if (std.mem.eql(u8, l, line)) return; } return error.LineNotFound; } test "sync §A.1 L4096-4101: round-1 buildRequest announces every holding with no gimme" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // §A.1 L4099-4101 — the three artifacts the client holds before the sync. _ = try ms.store().put(a1_manifest_hash, "manifest"); _ = try ms.store().put(a1_send_hash, "alpha"); _ = try ms.store().put(a1_held_hash, "ancestor"); var client = Client{ .operation = .sync, .self_servercode = a1_servercode, .project_code = a1_projectcode, .version = .{ .version = 22800, .datetime = .{ .date = 20260311, .time = 113146 } }, }; defer client.deinit(std.testing.allocator); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(std.testing.allocator); const body = try client.buildRequest(std.testing.allocator, ms.store(), &out); // §3.2 r22 — the client-version pragma is the first line (§10.1 L4096). try std.testing.expect(std.mem.startsWith(u8, body, "pragma client-version 22800 20260311 113146\n")); // §A.1 L4097-4098 — a bidirectional sync carries both pull and push cards. try expectHasLine(body, "pull " ++ a1_servercode ++ " " ++ a1_projectcode); try expectHasLine(body, "push " ++ a1_servercode ++ " " ++ a1_projectcode); // §A.1 L4099-4101 — one igot per held artifact, and no gimme (no phantoms). try expectHasLine(body, "igot " ++ a1_manifest_hash); try expectHasLine(body, "igot " ++ a1_send_hash); try expectHasLine(body, "igot " ++ a1_held_hash); try std.testing.expect(std.mem.indexOf(u8, body, "gimme ") == null); } test "sync §3.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 L1417-1427 — 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 L1419-1423, 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 L1441). 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 L858-860: an interned phantom emits gimme, then igot once content arrives" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().intern(a1_send_hash); // a phantom: content absent (§3.3 r24) var client = Client{ .operation = .sync }; defer client.deinit(std.testing.allocator); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(std.testing.allocator); // Phantom present ⇒ the client requests it with gimme, and does not igot it. const r1 = try client.buildRequest(std.testing.allocator, ms.store(), &out); try expectHasLine(r1, "gimme " ++ a1_send_hash); try std.testing.expect(std.mem.indexOf(u8, r1, "igot " ++ a1_send_hash) == null); // §3.3 L858-860 — storing content dephantomizes: now igot, no gimme. _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); const r2 = try client.buildRequest(std.testing.allocator, ms.store(), &out); try expectHasLine(r2, "igot " ++ a1_send_hash); try std.testing.expect(std.mem.indexOf(u8, r2, "gimme " ++ a1_send_hash) == null); } test "sync §6.1 L1919-1935 / §6.2 §6.4: a named credential emits a verifiable login card, anonymous none" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha"); const secret: [40]u8 = "0123456789abcdef0123456789abcdef01234567".*; var named = Client{ .operation = .sync, .self_servercode = a1_servercode, .project_code = a1_projectcode, .version = .{ .version = 22800, .datetime = .{ .date = 20260311, .time = 113146 } }, .credential = .{ .user = "drh", .secret_hex = secret }, }; defer named.deinit(std.testing.allocator); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(std.testing.allocator); const body = try named.buildRequest(std.testing.allocator, ms.store(), &out); // §6.2 — NONCE = SHA1(body bytes after the login LF); §6.4 SIGNATURE = // SHA1(NONCE || secret). Recompute over the actual tail and confirm the // emitted card matches, pinning the derivation to the RFC primitives. const login_start = std.mem.indexOf(u8, body, "\nlogin drh ").? + 1; const login_end = std.mem.indexOfScalarPos(u8, body, login_start, '\n').?; const tail = body[login_end + 1 ..]; const nonce = computeNonce(tail); const signature = computeSignature(&nonce, &secret); var want_buf: [128]u8 = undefined; const want = try std.fmt.bufPrint(&want_buf, "login drh {s} {s}", .{ &nonce, &signature }); try expectHasLine(body, want); // §6.1 L1933-1935 — an anonymous session emits no login card at all. var anon = Client{ .operation = .sync, .self_servercode = a1_servercode, .project_code = a1_projectcode, .version = .{ .version = 22800 } }; defer anon.deinit(std.testing.allocator); var anon_out: std.ArrayListUnmanaged(u8) = .empty; defer anon_out.deinit(std.testing.allocator); const anon_body = try anon.buildRequest(std.testing.allocator, ms.store(), &anon_out); try std.testing.expect(std.mem.indexOf(u8, anon_body, "login ") == null); } test "sync §5.3 L1789-1793: round_comment closes the request body after the content cards" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha"); var client = Client{ .operation = .sync, .round_comment = "deadbeef" }; defer client.deinit(std.testing.allocator); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(std.testing.allocator); const body = try client.buildRequest(std.testing.allocator, ms.store(), &out); try std.testing.expect(std.mem.endsWith(u8, body, "\n# deadbeef\n")); } fn consumeGimmeQueuesSend(gpa: std.mem.Allocator) !void { // Held-content gimme under a push queues exactly one send. The store lives // on the base allocator so only consumeReply/deinit see the failing one // (mirrors roundTablesNoLeak), covering the send_queue.getOrPut allocation. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .push, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), "gimme " ++ a1_send_hash ++ "\n"); try std.testing.expectEqual(@as(usize, 1), client.send_queue.count()); // §7.5 store path (private file with grant) under the same failing // allocator: exercises the store-gate branch and phantom remove. var private_ms = repostate.MemStore.init(std.testing.allocator); defer private_ms.deinit(); var granted = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer granted.deinit(gpa); _ = try granted.consumeReply(gpa, private_ms.store(), "private\nfile " ++ test_hash_a ++ " 5\nHELLO"); // §7.5 private-igot getPtr / public-igot getOrPut branches: the public igot // allocates the phantom entry (the failing allocation), the private igot // marks it via getPtr (allocation-free). var mark_ms = repostate.MemStore.init(std.testing.allocator); defer mark_ms.deinit(); var marker = Client{ .operation = .sync, .state = .exchange }; defer marker.deinit(gpa); _ = try marker.consumeReply(gpa, mark_ms.store(), "igot " ++ test_hash_a ++ "\nigot " ++ test_hash_a ++ " 1\n"); try std.testing.expectEqual(@as(usize, 1), marker.phantoms.count()); } test "sync §3.7(b)/§7.5: consumeReply queues a send and lands private content under allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeGimmeQueuesSend, .{}); } // §11.2 — the encoded `pull only ` payload (SP -> \s) and its non-matching // counterpart, as they arrive on the wire (App. B.8 message-card grammar). const msg_pull_only = "message pull\\sonly\\s\n"; const msg_non_match = "message hello\n"; // A held hash the server can gimme back; an unheld hash an igot phantoms. const held_gimme = "gimme " ++ a1_send_hash ++ "\n"; const unheld_igot = "igot " ++ a1_manifest_hash ++ "\n"; test "sync §11.2 r3: a message card does not abort or error the exchange" { // §11.2 preamble — "neither peer ... aborts the exchange in response to // this card alone". A well-formed two-token card, matching or non-matching, // dispatches without a fatal. Round termination under §3.7 is orthogonal; // the card never reaches `.aborted`. (Malformed arity aborts — covered by // the arity-failure tests below.) const replies = [_][]const u8{ msg_pull_only, msg_non_match }; for (replies) |body| { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(client.state != .aborted); } } test "sync §11.2: a one-token message card is rejected as an arity failure" { // §11.2 preamble (L3543) — "The card MUST consist of exactly two whitespace- // separated tokens". A bare `message` with no TEXT is a defined-operator // arity failure (§5.2 L1771-1773), which the client records as a local // warning and aborts on (§5.2 L1809-1812, §3.9 L1315) — not a silent // continue. The "never aborts" disposition governs only a well-formed card. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "message\n")); try std.testing.expectEqual(ClientState.aborted, client.state); } test "sync §11.2: a three-token message card is rejected as an arity failure" { // §11.2 preamble (L3543) — exactly two tokens. `message pull only` exposes // three visible tokens (operator + two args), a defined-operator arity // failure (§5.2 L1771-1773) that aborts (§5.2 L1809-1812). Distinct from a // §5 surplus (>6 tokens) rejection, which classifyCard fires pre-dispatch. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "message pull only\n")); try std.testing.expectEqual(ClientState.aborted, client.state); } test "sync §11.2 r15: the message arm no longer returns NotImplemented" { // §11.2 REWORK — the .message dispatch arm carries the full disposition; // it must not fall into the out-of-scope NotImplemented fence. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), msg_non_match); } test "sync §11.2 r2: consumeReply defossilizes TEXT before the pull only match" { // §11.2 preamble — decoding precedes matching. The raw token // `pull\sonly\sfor\snow` does not start with the literal prefix; only its // decoded form `pull only for now` matches and clears push. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expect(client.pushActive()); _ = try client.consumeReply(std.testing.allocator, ms.store(), "message pull\\sonly\\sfor\\snow\n"); try std.testing.expect(!client.pushActive()); } test "sync §11.2 r4/r5: a matching pull only * message silently clears push intent" { // §11.2 item 1 — push live + payload matches -> clear SYNC_PUSH, no abort. // The sans-IO layer performs no display, so r5 suppression holds trivially. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only); try std.testing.expect(!client.pushActive()); try std.testing.expect(client.state != .aborted); } test "sync §11.2 r6: cleared push intent persists across rounds" { // §11.2 item 1 — "a later round MUST NOT reinstate push based on this card // alone". Round 1 (igot keeps the round alive) clears push; round 2's gimme // for held content therefore queues nothing. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), unheld_igot ++ msg_pull_only); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expect(!client.pushActive()); const r2 = try client.consumeReply(std.testing.allocator, ms.store(), held_gimme); try std.testing.expect(!r2.content_queued); try std.testing.expectEqual(@as(usize, 0), client.send_queue.count()); try std.testing.expect(!client.pushActive()); } test "sync §11.2 r9: a non-matching message changes no per-session flag" { // §11.2 item 2 — "Non-matching payloads MUST NOT change any per-session // flag". Push stays live, so a following-round gimme still queues. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), unheld_igot ++ msg_non_match); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expect(client.pushActive()); const r2 = try client.consumeReply(std.testing.allocator, ms.store(), held_gimme); try std.testing.expect(r2.content_queued); try std.testing.expectEqual(@as(usize, 1), client.send_queue.count()); try std.testing.expect(client.pushActive()); } test "sync §11.2 r10/r12: a gimme before the clearing message keeps its queued send" { // §11.2 item 3 — "A gimme HASH that appears earlier ... is handled before // the clear fires ... that append is retained". The clear rewinds nothing. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), held_gimme ++ msg_pull_only); try std.testing.expectEqual(@as(usize, 1), client.send_queue.count()); try std.testing.expect(!client.pushActive()); } test "sync §11.2 r11: a gimme after the clearing message is not queued" { // §11.2 item 3a — "once SYNC_PUSH is clear it MUST NOT" append a responsive // content card for a later gimme in the same reply. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_send_hash, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only ++ held_gimme); try std.testing.expectEqual(@as(usize, 0), client.send_queue.count()); try std.testing.expect(!client.pushActive()); } test "sync §11.2 r13: clearing push leaves pull-side obtain intent unaffected" { // §11.2 item 3 — "Per-session flags other than SYNC_PUSH are unaffected". // A later igot in the same reply still creates its pull-side phantom. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only ++ unheld_igot); try std.testing.expect(obs.phantom_created); try std.testing.expectEqual(@as(usize, 1), client.phantoms.count()); try std.testing.expect(!client.pushActive()); } test "sync §11.2 r14: a matching message under pure pull falls through to advisory" { // §11.2 item 3e — SYNC_PUSH already clear (pure pull) excludes item 1; the // match is advisory, sets no flag, and never aborts. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expect(!client.pushActive()); _ = try client.consumeReply(std.testing.allocator, ms.store(), msg_pull_only); try std.testing.expect(!client.push_cleared); try std.testing.expect(client.state != .aborted); } fn consumeMessageClearsPush(gpa: std.mem.Allocator) !void { // §11.2 r2 — the message arm's defossilize scratch is the new alloc site. // The store lives on the base allocator so only consumeReply sees the // failing one (mirrors consumeGimmeQueuesSend). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), msg_pull_only); try std.testing.expect(!client.pushActive()); } test "sync §11.2 r2: consumeReply handles a message card under allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeMessageClearsPush, .{}); } fn nonWhitespaceByte(rand: std.Random) u8 { // Keep the fuzzed token to one whitespace-free field so it stays a // two-token card (§11.2 r1): no §5 surplus, so the abort path is never the // subject under test — only the defossilize + GLOB disposition is. while (true) { const b = rand.int(u8); if (!sync.isFossilWhitespace(b) and b != '\n') return b; } } test "sync §11.2 fuzz: a message card decodes+matches without ever aborting" { // Invariant (§11.2 item 1/2): after a single two-token message card, the // client clears push iff the DECODED payload matches `pull only ` — never // otherwise, and never a fatal abort. Fixed seed for exact replay. var prng = std.Random.DefaultPrng.init(0x11_2_FADE); const rand = prng.random(); var tok: [40]u8 = undefined; var card: [128]u8 = undefined; var dec: [40]u8 = undefined; var round: usize = 0; while (round < 512) : (round += 1) { // n >= 1: an empty token would collapse `message ` to one visible token, // now a §11.2 arity abort — out of this invariant's two-token scope. const n = 1 + rand.uintLessThan(usize, tok.len); for (tok[0..n]) |*b| b.* = nonWhitespaceByte(rand); const prefix = "message "; @memcpy(card[0..prefix.len], prefix); @memcpy(card[prefix.len..][0..n], tok[0..n]); card[prefix.len + n] = '\n'; const body = card[0 .. prefix.len + n + 1]; const decoded = artifact.decodeEscapes(tok[0..n], &dec); var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| { try std.testing.expect(e == error.OutOfMemory); continue; }; try std.testing.expect(client.state != .aborted); try std.testing.expectEqual(matchesPullOnly(decoded), client.push_cleared); } } test "sync §3.3 r25: igot creates a phantom only when unheld and obtainable" { const Case = struct { side: Side, op: Operation, already_holds: bool, want: IgotDisposition }; const cases = [_]Case{ .{ .side = .client, .op = .pull, .already_holds = false, .want = .create_phantom }, .{ .side = .client, .op = .pull, .already_holds = true, .want = .ignore }, .{ .side = .client, .op = .clone, .already_holds = false, .want = .create_phantom }, .{ .side = .client, .op = .push, .already_holds = false, .want = .ignore }, .{ .side = .server, .op = .push, .already_holds = false, .want = .create_phantom }, .{ .side = .server, .op = .pull, .already_holds = false, .want = .ignore }, .{ .side = .client, .op = .sync, .already_holds = false, .want = .create_phantom }, .{ .side = .server, .op = .sync, .already_holds = false, .want = .create_phantom }, }; for (cases) |c| try std.testing.expectEqual(c.want, igotDisposition(c.side, c.op, c.already_holds)); } test "sync §3.3 (a): peer-lacks hashes are excluded from gimme (rejection)" { try std.testing.expect(!gimmeEligible(.{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false })); try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = false, .private_sync_enabled = false })); } test "sync §3.3 (b): shunned hashes are excluded from gimme (rejection)" { try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = true, .private = false, .private_sync_enabled = false })); } test "sync §3.3 (c): private hashes excluded unless private sync enabled (rejection)" { try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false })); try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true })); } test "sync §3.3: receiving gimme records the peer lacks the hash" { var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerLacks(test_hash_a); try std.testing.expect(rt.peerLacks(test_hash_a)); try std.testing.expect(!gimmeEligible(.{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false })); } test "sync §3.3: held + sendable + disclosable content is sent" { try std.testing.expectEqual(GimmeResponse.send, gimmeResponse(true, true, true)); try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, true, false)); } test "sync §3.3: held but direction forbids sending is a silent no-send (rejection)" { try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, true)); try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, false)); } test "sync §3.3: gimme for unheld content is silently skipped (rejection)" { try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, true, true)); try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, false, false)); } test "sync §3.3 r24: a phantom is a hash known with content absent (store contract)" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const rid = try store.intern(test_hash_a); try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid)); _ = try store.put(test_hash_a, "payload"); try std.testing.expect(store.content(rid) != null); } test "sync §3.3 r36: storing content dephantomizes (store contract)" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const rid = try store.intern(test_hash_b); try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid)); _ = try store.put(test_hash_b, "content"); try std.testing.expect(store.content(rid) != null); } test "sync §3.2 r22: a request missing client-version is rejected" { try std.testing.expectEqual(RequestOrder.missing_client_version, checkRequestOrder(&.{})); try std.testing.expectEqual(RequestOrder.missing_client_version, checkRequestOrder(&.{.content_request})); try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&.{.client_version})); } test "sync §3.2 r23: client-version must precede content-requesting cards" { try std.testing.expectEqual(RequestOrder.version_after_content_request, checkRequestOrder(&.{ .content_request, .client_version })); try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&.{ .client_version, .content_request })); } test "sync §3.2 r23: send-private/login before client-version is accepted" { try std.testing.expectEqual(RequestOrder.ok, checkRequestOrder(&.{ .other, .client_version, .content_request })); } test "sync §8.1: clone card carries VERSION SEQNO, VERSION decimal 3" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &buf)); try std.testing.expectEqual(@as(u8, 3), clone_version); } test "sync §8.1: modern clone request and x-fossil-uncompressed reply (worked example)" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("clone 3 7", try writeCloneCard(7, &buf)); // Server-side cfile emission over storage-sequence order needs an // ArtifactStore iterator and is deferred; the reply media type it // uses is already on record here. const props = media_props.get(.x_fossil_uncompressed); try std.testing.expect(props.clone_reply_only); try std.testing.expect(!props.outer_compressed); try std.testing.expectEqualStrings("application/x-fossil-uncompressed", props.wire); } test "sync §8.1: clone version 3 orthogonal to the §7.6 release-integer gate" { var buf: [32]u8 = undefined; try std.testing.expect(@as(u32, clone_version) != sha3_min_peer_version); try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &buf)); // A release well below the SHA3 gate's floor still speaks clone 3. const sha3_hex = "a" ** 64; try std.testing.expectEqual(GateDecision.refuse, sha3PeerVersionGate(15000, sha3_hex)); } test "sync §8.2: continuing clone request carries clone 3 NEXT" { var buf: [32]u8 = undefined; try std.testing.expectEqual(NextRequest.clone_card, nextRequestKind(true, 5)); try std.testing.expectEqualStrings("clone 3 5", try writeCloneCard(5, &buf)); } test "sync §8.2: client MUST NOT send a clone card after clone_seqno 0 (rejection)" { try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, 0)); try std.testing.expect(NextRequest.clone_card != nextRequestKind(true, 0)); } test "sync §8.2: a cleanup round omits the clone card; empty work terminates" { try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, 0)); try std.testing.expectEqual(NextRequest.stop, nextRequestKind(false, 0)); } test "sync §8.2: server MUST advance when NEXT>0 and RIDs>=NEXT unsent (rejection)" { const Case = struct { next: i64, unsent: bool, want: bool }; const cases = [_]Case{ .{ .next = 5, .unsent = true, .want = true }, .{ .next = 10, .unsent = false, .want = false }, .{ .next = 0, .unsent = true, .want = false }, }; for (cases) |c| try std.testing.expectEqual(c.want, cloneServerMustAdvance(c.next, c.unsent)); } test "sync §8.2: modern clone loop (worked example)" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &buf)); // NEXT=5: continue with another clone card. var next = try parseCloneSeqno(tokenizeCard("clone_seqno 5")); try std.testing.expectEqual(@as(i64, 5), next); try std.testing.expectEqual(NextRequest.clone_card, nextRequestKind(true, next)); try std.testing.expectEqualStrings("clone 3 5", try writeCloneCard(@intCast(next), &buf)); // NEXT=0: artifact transfer done; a cleanup round, not a clone card. next = try parseCloneSeqno(tokenizeCard("clone_seqno 0")); try std.testing.expectEqual(@as(i64, 0), next); try std.testing.expect(!cloneSeqnoIsFatal(next)); try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, next)); // No remaining work: stop. try std.testing.expectEqual(NextRequest.stop, nextRequestKind(false, 0)); // A negative NEXT is the fatal path, never fed to nextRequestKind. next = try parseCloneSeqno(tokenizeCard("clone_seqno -1")); try std.testing.expectEqual(@as(i64, -1), next); try std.testing.expect(cloneSeqnoIsFatal(next)); try std.testing.expectError(error.Malformed, parseCloneSeqno(tokenizeCard("clone_seqno x"))); } test "sync §8.3: first clone round suppresses both body-login and cookie-login" { try std.testing.expect(!sendableAfterCloneRound0(.clone, 0)); try std.testing.expect(sendableAfterCloneRound0(.clone, 1)); } test "sync §8.3: first request is client-version then clone 3 1, no login (worked example)" { var clone_buf: [32]u8 = undefined; var version_buf: [64]u8 = undefined; try std.testing.expectEqualStrings("clone 3 1", try writeCloneCard(1, &clone_buf)); const version_line = try writeClientVersion(.{ .version = 20000 }, &version_buf); try std.testing.expect(std.mem.startsWith(u8, version_line, "pragma client-version ")); try std.testing.expect(!sendableAfterCloneRound0(.clone, 0)); } test "sync §8.3: after clone_seqno 0 phantoms resolve via the ordinary §3.7 path" { // Section 8.2's "cleanup round" is, by definition, an ordinary // request (L1930-1934): once NEXT hits 0 the CLI runs it as a plain // pull round, so continuation is §3.7(a) unchanged, not a new rule. try std.testing.expectEqual(NextRequest.cleanup_round, nextRequestKind(true, 0)); const pending = RoundObservation{ .operation = .pull, .versioned_content_delivered = true, .phantom_table_nonempty = true, }; try std.testing.expect(continueExchange(pending)); const nothing_pending = RoundObservation{ .operation = .pull }; try std.testing.expect(!continueExchange(nothing_pending)); } test "sync §8.3/§9.1: client reads project code from a push or pull card in the reply" { try std.testing.expectEqualStrings("PC", parsePushPull(tokenizeCard("push SC PC")).?.projectcode); try std.testing.expectEqualStrings("PC", parsePushPull(tokenizeCard("pull SC PC")).?.projectcode); try std.testing.expectEqual(@as(?PushPullCard, null), parsePushPull(tokenizeCard("push SC"))); } test "sync §9.1: push/pull/sync carry SERVERCODE PROJECTCODE; mismatch is fatal (rejection)" { var push_buf: [64]u8 = undefined; var pull_buf: [64]u8 = undefined; try std.testing.expectEqualStrings("push SC PC", try writePushPullCard(.push, "SC", "PC", &push_buf)); try std.testing.expectEqualStrings("pull SC PC", try writePushPullCard(.pull, "SC", "PC", &pull_buf)); // A combined sync carries both cards, each written by the one shared writer. try std.testing.expect(std.mem.startsWith(u8, push_buf[0..10], "push ")); try std.testing.expect(std.mem.startsWith(u8, pull_buf[0..10], "pull ")); try std.testing.expectEqual(SyncProjectCode.proceed, serverSyncProjectCodeDecision("PC", "PC")); try std.testing.expectEqual(SyncProjectCode.refuse_fatal, serverSyncProjectCodeDecision("PC", "XX")); try std.testing.expect(causeIsFatal(.exchange, .project_code_mismatch)); } test "sync §9.2: private igot suppressed for peers dated before 2020-04-13 (rejection)" { try std.testing.expectEqual(@as(u32, 20200413), private_igot_min_peer_date); const Case = struct { date: ?u32, want: PrivateIgotAction }; const cases = [_]Case{ .{ .date = 20200412, .want = .omit }, .{ .date = 20200413, .want = .announce_private }, // strict "before" .{ .date = 20200414, .want = .announce_private }, .{ .date = 19991231, .want = .omit }, .{ .date = null, .want = .announce_private }, }; for (cases) |c| try std.testing.expectEqual(c.want, privateIgotAction(c.date)); } test "sync §4 r1: request-target is repo path unchanged; empty uses /, never /xfer" { const cases = [_]struct { in: []const u8, want: []const u8 }{ .{ .in = "", .want = "/" }, .{ .in = "/", .want = "/" }, .{ .in = "/repo", .want = "/repo" }, .{ .in = "/cgi-bin/fossil/x", .want = "/cgi-bin/fossil/x" }, }; for (cases) |c| { const got = syncRequestTarget(c.in); try std.testing.expectEqualStrings(c.want, got); try std.testing.expect(!std.mem.endsWith(u8, got, "/xfer")); } } test "sync §4 r2: x-fossil and -debug are valid inbound request Content-Types" { try std.testing.expect(media_props.get(.x_fossil).valid_request_type); try std.testing.expect(media_props.get(.x_fossil_debug).valid_request_type); } test "sync §4 r3: x-fossil-uncompressed is not a request media type" { try std.testing.expect(!media_props.get(.x_fossil_uncompressed).valid_request_type); } test "sync §4 r4: SSH transport adds X-Fossil-Transport: SSH, the only nonstandard header" { try std.testing.expect(needsSshTransportHeader(.ssh)); try std.testing.expect(!needsSshTransportHeader(.http)); try std.testing.expect(!needsSshTransportHeader(.https)); try std.testing.expect(!needsSshTransportHeader(.file)); try std.testing.expectEqualStrings("X-Fossil-Transport: SSH", ssh_transport_header); } test "sync §4 r5: a non-POST sync request is redirected home, cards not processed" { try std.testing.expectEqual(MethodDisposition.redirect_home, serverMethodDisposition(false)); try std.testing.expectEqual(MethodDisposition.process, serverMethodDisposition(true)); } test "sync §4 r7: a Content-Length reply is read exactly that many bytes" { try std.testing.expectEqual(ReplyFraming{ .exact = 300 }, replyFraming(300)); } test "sync §4 r8: a reply with no Content-Length is read until close, cap 512 MiB" { try std.testing.expectEqual(ReplyFraming.until_close, replyFraming(null)); try std.testing.expectEqual(@as(usize, 536_870_912), reply_body_cap); } test "sync §4 r9: a reply exceeding the 512 MiB cap is rejected" { try replyCapCheck(reply_body_cap); try std.testing.expectError(error.ReplyTooLarge, replyCapCheck(reply_body_cap + 1)); } test "sync §4 r10: at most 19 HTTP redirects are followed for one exchange" { try std.testing.expectEqual(@as(usize, 19), max_redirects); try redirectAllowed(18); } test "sync §4 r11: the twentieth redirect aborts with a fatal redirect-limit error" { try std.testing.expectError(error.RedirectLimit, redirectAllowed(19)); try std.testing.expect(causeIsFatal(.exchange, .redirect_limit)); } test "sync §4 r12: a redirect must not downgrade HTTPS to plaintext HTTP" { try std.testing.expectError(error.InsecureRedirect, checkRedirectDowngrade(.https, .http)); try checkRedirectDowngrade(.https, .https); try checkRedirectDowngrade(.http, .http); } test "sync §4 r13: SSH or local-file transports never follow a redirect" { try std.testing.expect(!redirectAllowedForTransport(.ssh)); try std.testing.expect(!redirectAllowedForTransport(.file)); try std.testing.expect(redirectAllowedForTransport(.http)); try std.testing.expect(redirectAllowedForTransport(.https)); } test "sync §4 r14: a redirect strips trailing /xfer segments repeatedly, origin path never consulted" { const cases = [_]struct { location: []const u8, want: []const u8 }{ // clause 2 (repeated strip): /a/xfer/xfer -> /a/xfer -> /a, stop. .{ .location = "/a/xfer/xfer", .want = "/a" }, // clause 1 (single MUST strip): /mirror/xfer -> /mirror, stop. .{ .location = "https://b.example/mirror/xfer", .want = "https://b.example/mirror" }, // clause 3 (verbatim): no trailing /xfer segment. .{ .location = "/mirror/proj", .want = "/mirror/proj" }, // clause 4 (origin path never consulted): the old suffix-match // code stripped /proj against an origin /repo/proj; the new rule // has no origin arg and /proj != /xfer, so it's left verbatim. .{ .location = "https://b.example/mirror/proj", .want = "https://b.example/mirror/proj" }, // no path component present: nothing strippable. .{ .location = "https://b.example", .want = "https://b.example" }, // clause 1 at path root: authority preserved by the path-start floor. .{ .location = "https://b.example/xfer", .want = "https://b.example" }, // authority guard: "xfer" is the host, not a path segment. .{ .location = "https://xfer", .want = "https://xfer" }, }; for (cases) |c| { try std.testing.expectEqualStrings(c.want, stripRedirectXfer(c.location)); } } test "sync §4.1 r15: x-fossil is the compressed all-requests/pull-push-sync-reply type" { const p = media_props.get(.x_fossil); try std.testing.expectEqualStrings("application/x-fossil", p.wire); try std.testing.expect(p.outer_compressed); try std.testing.expect(p.valid_request_type); } test "sync §4.1 r16: x-fossil-debug is uncompressed and not for production" { const p = media_props.get(.x_fossil_debug); try std.testing.expectEqualStrings("application/x-fossil-debug", p.wire); try std.testing.expect(!p.outer_compressed); try std.testing.expect(!p.production_allowed); } test "sync §4.1 r17: x-fossil-uncompressed is clone-reply-only, not outer-zlib" { const p = media_props.get(.x_fossil_uncompressed); try std.testing.expectEqualStrings("application/x-fossil-uncompressed", p.wire); try std.testing.expect(p.clone_reply_only); try std.testing.expect(!p.outer_compressed); try std.testing.expect(!p.valid_request_type); } test "sync §4.1: parseMediaType inverts the media_props table" { for (std.meta.tags(MediaType)) |v| { try std.testing.expectEqual(v, parseMediaType(media_props.get(v).wire).?); } try std.testing.expect(parseMediaType("text/plain") == null); } test "sync §4.2 r22: a round is one POST+response ending at body exhaustion" { const body = "cookie x\ngimme " ++ ("a" ** 40) ++ "\n"; try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, body.len)); try std.testing.expectEqual(ReplyBodyStep.card_ready, replyBodyStep(body, 0)); } test "sync §4.2 r23: body exhaustion at a card boundary is not an error" { const body = "cookie x\n"; try std.testing.expectEqual(ReplyBodyStep.round_complete, replyBodyStep(body, body.len)); const partial = "cookie x"; try std.testing.expectEqual(ReplyBodyStep.truncated, replyBodyStep(partial, 0)); } test "sync §4.3: the cookie card is one opaque TEXT, in a request or a response" { var buf: [24]u8 = undefined; const written = writeCookieCard("s3cr3t", &buf); try std.testing.expectEqualStrings("cookie s3cr3t", written); const c = classifyCard(written); try std.testing.expectEqual(Disposition.dispatch, c.disposition); var store: CookieStore = .{}; var decode_buf: [16]u8 = undefined; const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?; store.store(decoded); try std.testing.expectEqualStrings("s3cr3t", store.value().?); // Same shape either direction: the server's emit and the client's // return are both exactly this card. var buf2: [24]u8 = undefined; try std.testing.expectEqualStrings(written, writeCookieCard(store.value().?, &buf2)); } test "sync §A.5/§6.2 r5/§12.1-r2: computeNonce('example-request-body-bytes')" { // §12.1-r2 L2709-2711 restates this: the login NONCE is the SHA-1 digest // of the message-body bytes that follow the card — exactly this vector. const nonce = computeNonce("example-request-body-bytes"); try std.testing.expectEqualStrings("5dfa03bd8645938b4e312a4fcdad5ebe6d4d4840", &nonce); } test "sync §A.5/§6.3 r7: deriveSharedSecret(project,alice,secret-passphrase)" { const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase"); try std.testing.expectEqualStrings("d7a235115bf97d126c42140196c4fc6087312353", &secret); } test "sync §A.5/§6.4 r8: computeSignature(nonce,secret)" { const nonce = computeNonce("example-request-body-bytes"); const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase"); const signature = computeSignature(&nonce, &secret); try std.testing.expectEqualStrings("e3dba1c99442823b76ae64a8afca940e0327f9f8", &signature); } test "sync §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/§12.1-r3: distinct body tails yield distinct nonces (replay note)" { // §12.1-r3 L2711-2713: independently generated rounds produce distinct // nonces. Its causal mechanism — the per-request random comment line of // §5.3 (L2711-2712) — is an RNG/CLI-layer concern deliberately out of this // sans-IO scope; the co-citation covers only the distinct-tail => // distinct-nonce consequence, not the randomness source. const n1 = computeNonce("round one body bytes"); const n2 = computeNonce("round two body bytes"); try std.testing.expect(!std.mem.eql(u8, &n1, &n2)); } test "sync §12.1-r1: neither password nor shared secret appears in the login body card" { // §12.1 L2704-2707: the mechanism transmits neither the user password nor // the shared secret; neither is carried on the wire. The §6.1 body card's // fields are USER/NONCE/SIGNATURE, and SIGNATURE = SHA-1(NONCE || secret) // is one-way — so neither the password nor the 40-hex secret can appear. const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase"); try std.testing.expectEqualStrings("d7a235115bf97d126c42140196c4fc6087312353", &secret); // §A.5 vector const nonce = computeNonce("example-request-body-bytes"); const sig = computeSignature(&nonce, &secret); var card_buf: [128]u8 = undefined; const card = try std.fmt.bufPrint(&card_buf, "login alice {s} {s}", .{ &nonce, &sig }); try std.testing.expect(std.mem.indexOf(u8, card, "secret-passphrase") == null); try std.testing.expect(std.mem.indexOf(u8, card, &secret) == null); } test "sync §6.1: login USER NONCE SIGNATURE parses (worked card, §A.3 L3236)" { const line = "login privuser ee71329ef84fb77b428c6f626ad0103a426e0652 c3ee0be781c9ebcfc6e24756cbb75e6319ca255b"; const card = parseLoginCard(tokenizeCard(line)).?; try std.testing.expectEqualStrings("privuser", card.user); try std.testing.expectEqualStrings("ee71329ef84fb77b428c6f626ad0103a426e0652", card.nonce); try std.testing.expectEqualStrings("c3ee0be781c9ebcfc6e24756cbb75e6319ca255b", card.signature); } test "sync §6.1: login card rejected — wrong token count" { try std.testing.expect(parseLoginCard(tokenizeCard("login onlyuser")) == null); try std.testing.expect(parseLoginCard(tokenizeCard("login a b c d")) == null); } test "sync §6.1: login card rejected — NONCE not 40 lowercase hex" { const sig = "c3ee0be781c9ebcfc6e24756cbb75e6319ca255b"; const cases = [_][]const u8{ "login u ee71329ef84fb77b428c6f626ad0103a426e065 " ++ sig, // 39 chars, too short "login u ee71329ef84fb77b428c6f626ad0103a426e06520 " ++ sig, // 41 chars, too long "login u EE71329EF84FB77B428C6F626AD0103A426E0652 " ++ sig, // uppercase "login u ge71329ef84fb77b428c6f626ad0103a426e0652 " ++ sig, // non-hex 'g' }; for (cases) |line| { try std.testing.expect(parseLoginCard(tokenizeCard(line)) == null); } } test "sync §6.1: login card rejected — SIGNATURE not 40 lowercase hex" { const nonce = "ee71329ef84fb77b428c6f626ad0103a426e0652"; const cases = [_][]const u8{ "login u " ++ nonce ++ " c3ee0be781c9ebcfc6e24756cbb75e6319ca25", // 38 chars, too short "login u " ++ nonce ++ " c3ee0be781c9ebcfc6e24756cbb75e6319ca255bff", // 42 chars, too long "login u " ++ nonce ++ " C3EE0BE781C9EBCFC6E24756CBB75E6319CA255B", // uppercase "login u " ++ nonce ++ " z3ee0be781c9ebcfc6e24756cbb75e6319ca255b", // non-hex 'z' }; for (cases) |line| { try std.testing.expect(parseLoginCard(tokenizeCard(line)) == null); } } test "sync §6.1: login card rejected — empty/whitespace USER" { const nonce = "ee71329ef84fb77b428c6f626ad0103a426e0652"; const sig = "c3ee0be781c9ebcfc6e24756cbb75e6319ca255b"; // Constructed directly: tokenizeCard itself never yields an empty or // whitespace-bearing token, so this exercises the defensive check for // a Tokens value assembled from a non-tokenizeCard source. var empty_user: Tokens = .{ .len = 4 }; empty_user.items = .{ "login", "", nonce, sig, undefined, undefined }; try std.testing.expect(parseLoginCard(empty_user) == null); var ws_user: Tokens = .{ .len = 4 }; ws_user.items = .{ "login", "us er", nonce, sig, undefined, undefined }; try std.testing.expect(parseLoginCard(ws_user) == null); } test "sync §A.3/§6.5 r?: writeCookieLogin encodes SP->+ (privuser round-2 card, L3268)" { const card: LoginCard = .{ .user = "privuser", .nonce = "653828a8fc753c1685501de457c104f0b20938c9", .signature = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", }; var buf: [128]u8 = undefined; const cookie = writeCookieLogin(card, &buf).?; try std.testing.expectEqualStrings( "login+privuser+653828a8fc753c1685501de457c104f0b20938c9+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", cookie, ); } test "sync §6.5: parseCookieLogin round-trips the L3268 x-f-l-c value" { const value = "login+privuser+653828a8fc753c1685501de457c104f0b20938c9+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; var buf: [128]u8 = undefined; const card = parseCookieLogin(value, &buf).?; try std.testing.expectEqualStrings("privuser", card.user); try std.testing.expectEqualStrings("653828a8fc753c1685501de457c104f0b20938c9", card.nonce); try std.testing.expectEqualStrings("bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", card.signature); } test "sync §6.5: parseCookieLogin rejects a malformed value" { var buf: [128]u8 = undefined; try std.testing.expect(parseCookieLogin("login+privuser+short+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", &buf) == null); try std.testing.expect(parseCookieLogin("login+privuser+%zz+bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", &buf) == null); } test "sync §12.2-r5: writeCookieLogin never places the shared secret in the x-f-l-c cookie" { // §12.2 L2733-2734 (+ §12 preamble L2679-2681): a shared secret MUST NOT be // stored in a cookie. The x-f-l-c fields are login+USER+NONCE+SIGNATURE — // the 40-hex secret and the password are not among them. Distinct surface // from §12.1-r1: cookie transport, not the body card. const secret = deriveSharedSecret("c1b2a3d4e5f60718293a4b5c6d7e8f9012345678", "alice", "secret-passphrase"); const nonce = computeNonce("example-request-body-bytes"); const sig = computeSignature(&nonce, &secret); const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &sig }; var buf: [128]u8 = undefined; const cookie = writeCookieLogin(card, &buf).?; try std.testing.expect(std.mem.indexOf(u8, cookie, &secret) == null); try std.testing.expect(std.mem.indexOf(u8, cookie, "secret-passphrase") == null); } test "sync §6.4 step1: nobody/anonymous succeed unconditionally" { const dummy = "0" ** 40; const nobody: LoginCard = .{ .user = "nobody", .nonce = dummy, .signature = dummy }; try std.testing.expectEqual(LoginResult.special_case, serverVerifyLogin(nobody, "x", "x", "x")); const anon: LoginCard = .{ .user = "anonymous", .nonce = dummy, .signature = dummy }; try std.testing.expectEqual(LoginResult.special_case, serverVerifyLogin(anon, "x", "x", "x")); } test "sync §6.4 step2: primary signature check passes (stored bytes as secret) with valid tail-hash" { const body_tail = "some request body tail bytes"; const nonce = computeNonce(body_tail); const stored_secret = "arbitrary-stored-credential-bytes"; const signature = computeSignature(&nonce, stored_secret); const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &signature }; try std.testing.expectEqual(LoginResult.ok, serverVerifyLogin(card, stored_secret, "unused-project-code", body_tail)); } test "sync §6.4 step3: stored-credential fallback re-derives only when primary fails and stored len != 40" { const body_tail = "another body tail"; const nonce = computeNonce(body_tail); const project_code = "c1b2a3d4e5f60718293a4b5c6d7e8f9012345678"; const login = "alice"; const cleartext_password = "secret-passphrase"; // stored credential, len != 40 const shared_secret = deriveSharedSecret(project_code, login, cleartext_password); const signature = computeSignature(&nonce, &shared_secret); const card: LoginCard = .{ .user = login, .nonce = &nonce, .signature = &signature }; // The primary form (cleartext_password treated directly as the raw // secret) does not match; only the §6.3 fallback re-derivation does. try std.testing.expectEqual(LoginResult.ok, serverVerifyLogin(card, cleartext_password, project_code, body_tail)); } test "sync §6.4: login rejected — signature mismatch" { const body_tail = "reject sig body"; const nonce = computeNonce(body_tail); const stored_secret = "cred-bytes"; const wrong_signature = "0" ** 40; const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = wrong_signature }; try std.testing.expectEqual(LoginResult.reject, serverVerifyLogin(card, stored_secret, "project", body_tail)); } test "sync §6.4: login rejected — tail-hash mismatch" { const original_tail = "original body tail bytes"; const tampered_tail = "tampered body tail bytes!!"; const nonce = computeNonce(original_tail); const stored_secret = "cred-bytes"; const signature = computeSignature(&nonce, stored_secret); const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &signature }; // The server recomputes NONCE from the body tail it actually // received, which is the tampered one — signature is valid but the // recomputed tail hash no longer matches the carried NONCE. try std.testing.expectEqual(LoginResult.reject, serverVerifyLogin(card, stored_secret, "project", tampered_tail)); } test "sync §6.4: fallback NOT attempted when stored len == 40" { const body_tail = "fallback-gate body"; const nonce = computeNonce(body_tail); const project_code = "project-code"; const login = "alice"; const password_40 = "x" ** 40; // exactly 40 bytes: a cleartext-shaped credential const would_be_fallback_secret = deriveSharedSecret(project_code, login, password_40); const signature = computeSignature(&nonce, &would_be_fallback_secret); const card: LoginCard = .{ .user = login, .nonce = &nonce, .signature = &signature }; // If the fallback were (incorrectly) attempted despite stored.len == // 40, this signature would verify. §6.4 step 3 gates the fallback on // stored.len != 40, so the primary-only check must reject. try std.testing.expectEqual(LoginResult.reject, serverVerifyLogin(card, password_40, project_code, body_tail)); } test "sync §12.1-r4: a byte-identical login verifies .ok twice (no nonce cache, no freshness check)" { // §12.1 L2713-2717: the server maintains no nonce cache and applies no // timestamp freshness check, so a complete request can be replayed // byte-for-byte and still pass. serverVerifyLogin is a pure function of its // inputs — identical inputs return .ok both times, pinning that property. const body_tail = "some request body tail bytes"; const nonce = computeNonce(body_tail); const stored_secret = "arbitrary-stored-credential-bytes"; const sig = computeSignature(&nonce, stored_secret); const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &sig }; const first = serverVerifyLogin(card, stored_secret, "unused-project-code", body_tail); const second = serverVerifyLogin(card, stored_secret, "unused-project-code", body_tail); try std.testing.expectEqual(LoginResult.ok, first); try std.testing.expectEqual(LoginResult.ok, second); } test "sync §6.5 r1: cookie gate requires peer VERSION >= 22700 (Fossil 2.27)" { const dt: DateTime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time }; const cases = [_]struct { version: u32, want: bool }{ .{ .version = 22699, .want = false }, .{ .version = 22700, .want = true }, .{ .version = 22701, .want = true }, }; for (cases) |c| { const peer: Peer = .{ .version = c.version, .datetime = dt }; try std.testing.expectEqual(c.want, peerGatedForCookie(peer)); } } test "sync §6.5 r2: cookie gate requires timestamp on or after 2025-07-27 11:05:00" { const cases = [_]struct { date: u32, time: u32, want: bool }{ .{ .date = 20250726, .time = 999999, .want = false }, .{ .date = 20250727, .time = 110459, .want = false }, .{ .date = 20250727, .time = 110500, .want = true }, .{ .date = 20250727, .time = 110501, .want = true }, .{ .date = 20250728, .time = 0, .want = true }, }; for (cases) |c| { const peer: Peer = .{ .version = cookie_gate_min_version, .datetime = .{ .date = c.date, .time = c.time } }; try std.testing.expectEqual(c.want, peerGatedForCookie(peer)); } } test "sync §6.5 r3: both conditions met -> cookie transport" { const named = Identity{ .named = "alice" }; const peer: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }; try std.testing.expectEqual(LoginTransport.cookie, loginTransport(named, peer, .pull, 1)); try std.testing.expect(loginTransport(named, peer, .pull, 1).emitsCookieHeader()); } test "sync §6.5 r4: either condition unmet -> body-embedded login fallback" { const named = Identity{ .named = "alice" }; const cases = [_]Peer{ .{ .version = 22699, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }, // low version .{ .version = cookie_gate_min_version, .datetime = .{ .date = 20250726, .time = 235959 } }, // early date .{ .version = cookie_gate_min_version, .datetime = .{ .date = cookie_gate_min_date, .time = 110459 } }, // early time }; for (cases) |peer| { try std.testing.expectEqual(LoginTransport.body, loginTransport(named, peer, .pull, 1)); try std.testing.expect(loginTransport(named, peer, .pull, 1).emitsBodyLogin()); } } test "sync §6.5 r5: unobserved server-version (null timestamp) falls back to body" { const named = Identity{ .named = "alice" }; const peer = Peer.clientInit(23000); try std.testing.expect(peer.datetime == null); try std.testing.expect(!peerGatedForCookie(peer)); try std.testing.expectEqual(LoginTransport.body, loginTransport(named, peer, .pull, 1)); } test "sync §6.5 r6: ungated peer never emits the x-f-l-c cookie header" { const named = Identity{ .named = "alice" }; const cases = [_]Peer{ .{ .version = 22699, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }, .{ .version = cookie_gate_min_version, .datetime = .{ .date = 20250101, .time = 0 } }, Peer.clientInit(23000), }; for (cases) |peer| { try std.testing.expect(!loginTransport(named, peer, .pull, 1).emitsCookieHeader()); } } test "sync §6.5 r7: cookie login plus body login is a §11.1 duplicate_login error (rejection)" { const cases = [_]struct { cookie: bool, body: usize, want: bool }{ .{ .cookie = true, .body = 1, .want = true }, // r7: cookie + body login together .{ .cookie = false, .body = 2, .want = true }, // §6.1 two body logins .{ .cookie = true, .body = 0, .want = false }, .{ .cookie = false, .body = 1, .want = false }, .{ .cookie = false, .body = 0, .want = false }, }; for (cases) |c| { try std.testing.expectEqual(c.want, duplicateLogin(c.cookie, c.body)); } try std.testing.expectEqual(ErrorReset.reset, serverErrorReset(.duplicate_login)); } test "sync §6.5 r8: server accepts a body login irrespective of cookie gating" { try std.testing.expect(!duplicateLogin(false, 1)); const body_tail = "r8 body tail bytes"; const nonce = computeNonce(body_tail); const stored_secret = "r8-stored-credential"; const signature = computeSignature(&nonce, stored_secret); const card: LoginCard = .{ .user = "alice", .nonce = &nonce, .signature = &signature }; // serverVerifyLogin takes no version/gate input: acceptance of a body // login is structurally ungated, even for a peer that would also // qualify for the cookie transport. try std.testing.expectEqual(LoginResult.ok, serverVerifyLogin(card, stored_secret, "project", body_tail)); } test "sync §6.5 r9: cookie-login mode suppresses the body login card" { const named = Identity{ .named = "alice" }; const peer: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }; const transport = loginTransport(named, peer, .pull, 1); try std.testing.expectEqual(LoginTransport.cookie, transport); try std.testing.expect(!transport.emitsBodyLogin()); } test "sync §6.2 [RFC3174] §7.3: computeNonce('abc') == a9993e364706816aba3e25717850c26c9cd0d89d (absolute anchor)" { // §6.2 L1941-1945: NONCE = SHA1(body bytes after the login-card LF). // computeNonce('abc') is therefore SHA1('abc'), which RFC3174 §7.3 TEST1 // pins absolutely. const nonce = computeNonce("abc"); try std.testing.expectEqualStrings("a9993e364706816aba3e25717850c26c9cd0d89d", &nonce); } test "sync §6.2: the nonce excludes the login line (same tail, different login -> same nonce)" { // §6.2 L1941-1945: only the bytes AFTER the login-card LF feed the nonce. // Two requests that differ SOLELY in their login line but share the same // tail must yield the same nonce; hashing the whole request (login line // included) yields a different value, proving the login line is excluded. const tail = "the shared body tail bytes after the login line"; const req_a = "login alice " ++ ("a" ** 40) ++ " " ++ ("b" ** 40) ++ "\n" ++ tail; const req_b = "login bob " ++ ("c" ** 40) ++ " " ++ ("d" ** 40) ++ "\n" ++ tail; try std.testing.expect(!std.mem.eql(u8, req_a, req_b)); // the two requests genuinely differ const tail_a = req_a[std.mem.indexOfScalar(u8, req_a, '\n').? + 1 ..]; const tail_b = req_b[std.mem.indexOfScalar(u8, req_b, '\n').? + 1 ..]; try std.testing.expectEqualStrings(tail, tail_a); try std.testing.expectEqualStrings(tail, tail_b); const nonce_a = computeNonce(tail_a); const nonce_b = computeNonce(tail_b); try std.testing.expectEqualStrings(&nonce_a, &nonce_b); // same tail -> same nonce despite different logins // Hashing the full request (login line + tail) instead would differ. try std.testing.expect(!std.mem.eql(u8, &nonce_a, &computeNonce(req_a))); try std.testing.expect(!std.mem.eql(u8, &nonce_a, &computeNonce(req_b))); } test "sync §6.2: nonce is sensitive to body-tail suffix perturbation (changed tail -> changed nonce)" { // §6.2 L1941-1945: the nonce covers the tail through end of body, so any // change to those bytes changes the nonce. const base = computeNonce("body tail bytes"); const changed = computeNonce("body tail bytesX"); try std.testing.expect(!std.mem.eql(u8, &base, &changed)); } test "sync §6.1: loginTransport is .none for an anonymous identity (no login card)" { // §6.1 L1932-1934: a login card MUST NOT be emitted for anonymous. const gated: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }; const id = Identity.fromLoginName("anonymous"); try std.testing.expectEqual(LoginTransport.none, loginTransport(id, gated, .pull, 1)); try std.testing.expect(!loginTransport(id, gated, .pull, 1).emitsBodyLogin()); try std.testing.expect(!loginTransport(id, gated, .pull, 1).emitsCookieHeader()); } test "sync §6.1: loginTransport is .none for a nobody identity (no login card)" { // §6.1 L1932-1934: a login card MUST NOT be emitted for nobody. const gated: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }; const id = Identity.fromLoginName("nobody"); try std.testing.expectEqual(LoginTransport.none, loginTransport(id, gated, .pull, 1)); } test "sync §8.3: loginTransport is .none for a named identity on clone round 0 (neither login form)" { // §8.3 L1974-1975: the first round of a protocol-version-3 clone omits // both body-login and cookie-login, even for a named, gated peer. const gated: Peer = .{ .version = 22700, .datetime = .{ .date = cookie_gate_min_date, .time = cookie_gate_min_time } }; const named = Identity{ .named = "alice" }; try std.testing.expectEqual(LoginTransport.none, loginTransport(named, gated, .clone, 0)); // A later clone round (round 1) resumes normal transport (cookie here). try std.testing.expectEqual(LoginTransport.cookie, loginTransport(named, gated, .clone, 1)); } test "sync §6.1: writeLogin emits `login USER NONCE SIGNATURE` with single-SP separators over a fossilized USER" { // §6.1 L1922-1926: the body-card form is `login USER NONCE SIGNATURE`. // USER is emitted verbatim as the already-fossilized token, so an // escaped whitespace (backslash-s) stays a single token. const nonce = "653828a8fc753c1685501de457c104f0b20938c9"; const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; var buf: [128]u8 = undefined; const plain = try writeLogin(.{ .user = "alice", .nonce = nonce, .signature = sig }, &buf); try std.testing.expectEqualStrings("login alice " ++ "653828a8fc753c1685501de457c104f0b20938c9" ++ " " ++ "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", plain); var buf2: [128]u8 = undefined; const escaped = try writeLogin(.{ .user = "user\\sname", .nonce = nonce, .signature = sig }, &buf2); try std.testing.expectEqualStrings("login user\\sname " ++ "653828a8fc753c1685501de457c104f0b20938c9" ++ " " ++ "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2", escaped); } test "sync §B.4: cookie value — ~ passes unescaped, SP -> +, $ / : are percent-encoded" { // §B.4 L4630-4631: pct-user unreserved = ALPHA / DIGIT / - . _ ~ ; every // other byte percent-encoded. So `~` passes, SP -> '+', and `$ / :` // become %24 %2F %3A. (The RFC3986 unreserved set is normative here — see // rfc_ambiguity re: a claimed wider fossil-canonical alphabet.) const nonce = "653828a8fc753c1685501de457c104f0b20938c9"; const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; var buf: [160]u8 = undefined; const cookie = writeCookieLogin(.{ .user = "a~b$c/d:e f", .nonce = nonce, .signature = sig }, &buf).?; try std.testing.expectEqualStrings("login+a~b%24c%2Fd%3Ae+f+" ++ nonce ++ "+" ++ sig, cookie); } test "sync §B.4: cookie value — * becomes %2A" { const nonce = "653828a8fc753c1685501de457c104f0b20938c9"; const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; var buf: [160]u8 = undefined; const cookie = writeCookieLogin(.{ .user = "a*b", .nonce = nonce, .signature = sig }, &buf).?; try std.testing.expectEqualStrings("login+a%2Ab+" ++ nonce ++ "+" ++ sig, cookie); } test "sync §B.4: cookie value — backslash becomes %5C" { const nonce = "653828a8fc753c1685501de457c104f0b20938c9"; const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; var buf: [160]u8 = undefined; const cookie = writeCookieLogin(.{ .user = "a\\b", .nonce = nonce, .signature = sig }, &buf).?; try std.testing.expectEqualStrings("login+a%5Cb+" ++ nonce ++ "+" ++ sig, cookie); } test "sync §B.4: cookie value — a non-ASCII byte becomes uppercase %XX" { const nonce = "653828a8fc753c1685501de457c104f0b20938c9"; const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; var buf: [160]u8 = undefined; // 0xC3 is the lead byte of a UTF-8 'Ã'; it must encode as uppercase %C3. const cookie = writeCookieLogin(.{ .user = "a\xc3b", .nonce = nonce, .signature = sig }, &buf).?; try std.testing.expectEqualStrings("login+a%C3b+" ++ nonce ++ "+" ++ sig, cookie); } test "sync §B.4: cookie decode accepts case-insensitive %XX but rejects %20 in a separator slot" { // §B.4 L4629-4632: url-login-value = "login+" pct-user "+" nonce "+" // signature, with pct-encoded = "%" HEXDIG-CI HEXDIG-CI. The field // separator is the literal "+" only; the sole leniency the grammar // licenses is HEXDIG-CI (a pct-encoded byte spelled in either hex case). // A lowercase escape therefore decodes exactly as its uppercase-hex // canonical form. Using %20 where a "+" separator belongs is NOT a // grammar separator and MUST be rejected. const nonce = "653828a8fc753c1685501de457c104f0b20938c9"; const sig = "bec32fbc6a2589413ee6d1f47a4ae0098dafcad2"; // '*' is not unreserved (§B.4 L4631), so writeCookieLogin percent-encodes // it with uppercase hex: %2A. var canon_buf: [160]u8 = undefined; const canonical = writeCookieLogin(.{ .user = "a*b", .nonce = nonce, .signature = sig }, &canon_buf).?; try std.testing.expectEqualStrings("login+a%2Ab+" ++ nonce ++ "+" ++ sig, canonical); var a: [160]u8 = undefined; const from_canon = parseCookieLogin(canonical, &a).?; // The same value with the escape spelled in lowercase hex (%2a) decodes // to the identical card -- HEXDIG-CI is case-insensitive. const lower = "login+a%2ab+" ++ nonce ++ "+" ++ sig; var b: [160]u8 = undefined; const from_lower = parseCookieLogin(lower, &b).?; try std.testing.expectEqualStrings("a*b", from_lower.user); try std.testing.expectEqualStrings(from_canon.user, from_lower.user); try std.testing.expectEqualStrings(from_canon.nonce, from_lower.nonce); try std.testing.expectEqualStrings(from_canon.signature, from_lower.signature); // %20 in the separator positions is not the grammar's "+" separator; the // flat form-urldecode leniency that accepted it was not RFC-derived. const sep_as_pct20 = "login%20a%2ab%20" ++ nonce ++ "%20" ++ sig; var c: [160]u8 = undefined; try std.testing.expect(parseCookieLogin(sep_as_pct20, &c) == null); } test "sync §6.1/§6.4 fuzz: deriveSharedSecret/computeNonce/computeSignature never crash and return 40 lowercase-hex bytes" { // Seeded PRNG over bounded, precondition-respecting inputs: the three §6 // digest funnels always produce exactly 40 lowercase-hex bytes and never // trip a bound. Reproduces exactly from the fixed seed. var prng = std.Random.DefaultPrng.init(0x5116_C0DE); const rand = prng.random(); const hex_lower = "0123456789abcdef"; var round: usize = 0; while (round < 500) : (round += 1) { // login: 1..16 bytes of lowercase letters (non-empty, no whitespace). var login_buf: [16]u8 = undefined; const login_len = 1 + rand.uintLessThan(usize, login_buf.len); for (login_buf[0..login_len]) |*c| c.* = 'a' + rand.uintLessThan(u8, 26); const login = login_buf[0..login_len]; var pc_buf: [40]u8 = undefined; for (&pc_buf) |*c| c.* = rand.int(u8); var pw_buf: [64]u8 = undefined; const pw_len = rand.uintLessThan(usize, pw_buf.len + 1); for (pw_buf[0..pw_len]) |*c| c.* = rand.int(u8); const secret = deriveSharedSecret(&pc_buf, login, pw_buf[0..pw_len]); var tail_buf: [256]u8 = undefined; const tail_len = rand.uintLessThan(usize, tail_buf.len + 1); for (tail_buf[0..tail_len]) |*c| c.* = rand.int(u8); const nonce = computeNonce(tail_buf[0..tail_len]); // nonce_hex is itself 40 lowercase hex; secret is a 40-hex string. const sig = computeSignature(&nonce, &secret); for ([_][]const u8{ &secret, &nonce, &sig }) |digest| { try std.testing.expectEqual(@as(usize, 40), digest.len); for (digest) |c| try std.testing.expect(std.mem.indexOfScalar(u8, hex_lower, c) != null); } } } test "sync §6.6 r1: the server assigns capability letters o/i/g/x; other bytes carry none" { try std.testing.expectEqual(Capability.read, capabilityFromLetter('o').?); try std.testing.expectEqual(Capability.write, capabilityFromLetter('i').?); try std.testing.expectEqual(Capability.clone, capabilityFromLetter('g').?); try std.testing.expectEqual(Capability.private, capabilityFromLetter('x').?); for ("aszA\x00") |letter| try std.testing.expectEqual(@as(?Capability, null), capabilityFromLetter(letter)); const full = capabilitySetFromLetters("oigx"); try std.testing.expect(full.contains(.read) and full.contains(.write) and full.contains(.clone) and full.contains(.private)); try std.testing.expectEqual(@as(usize, 0), capabilitySetFromLetters("").count()); // unknown byte 'Q' is skipped, not phantom-inserted; 'o' is still held. try std.testing.expect(capabilitySetFromLetters("oQ").contains(.read)); try std.testing.expectEqual(@as(usize, 1), capabilitySetFromLetters("oQ").count()); } test "sync §6.6 r2: Read (o) permits pull" { try std.testing.expectEqual(Capability.read, capabilityFromLetter('o').?); try std.testing.expectEqual(Capability.read, requiredCapability(.pull).?); try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(capabilitySetFromLetters("o"), .pull)); } test "sync §6.6 r3: Write (i) permits push" { try std.testing.expectEqual(Capability.write, capabilityFromLetter('i').?); try std.testing.expectEqual(Capability.write, requiredCapability(.push).?); try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(capabilitySetFromLetters("i"), .push)); } test "sync §6.6 r4: Clone (g) permits clone" { try std.testing.expectEqual(Capability.clone, capabilityFromLetter('g').?); try std.testing.expectEqual(Capability.clone, requiredCapability(.clone).?); try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(capabilitySetFromLetters("g"), .clone)); } test "sync §6.6 r5: Private (x) permits private-content transfer" { try std.testing.expectEqual(Capability.private, capabilityFromLetter('x').?); const caps = capabilitySetFromLetters("x"); try std.testing.expect(caps.contains(.private)); // realized by the existing §7.5/§10.5 gates, not re-keyed here. try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(caps.contains(.private))); try std.testing.expectEqual(PrivateReceipt.store, privateReceipt(caps.contains(.private))); } test "sync §6.6 r6: administrator capabilities permit configuration (no letter)" { try std.testing.expectEqual(@as(?Capability, null), capabilityFromLetter('a')); try std.testing.expectEqual(@as(?Capability, null), capabilityFromLetter('s')); // admin's configuration/user-management gate is §10.3's decider. try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true)); try std.testing.expectEqual(ConfigPushDecision.reject, serverConfigPushDecision(false)); } test "sync §6.6 r7: the server checks the capability appropriate to each processed card" { try std.testing.expectEqual(Capability.read, requiredCapability(.pull).?); try std.testing.expectEqual(Capability.write, requiredCapability(.push).?); try std.testing.expectEqual(Capability.clone, requiredCapability(.clone).?); try std.testing.expectEqual(@as(?Capability, null), requiredCapability(.sync)); const full = capabilitySetFromLetters("oig"); const ops = [_]Operation{ .pull, .push, .clone }; for (ops) |op| try std.testing.expectEqual(CapabilityDecision.process, serverCapabilityDecision(full, op)); } test "sync §6.6 r8: an unauthorized pull (no o) is rejected with an error card" { try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(capabilitySetFromLetters("ig"), .pull)); try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(CapabilitySet.empty, .pull)); } test "sync §6.6 r8: an unauthorized push (no i) is rejected with an error card" { try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(capabilitySetFromLetters("og"), .push)); } test "sync §6.6 r8: an unauthorized clone (no g) is rejected with an error card" { try std.testing.expectEqual(CapabilityDecision.reject, serverCapabilityDecision(capabilitySetFromLetters("oi"), .clone)); } test "sync §6.4: a login grant is additive — union never clears a baseline bit" { // §6.4 L2021-2024: a successful login card can only add capability bits // on top of the baseline, never remove any; the two contributors combine // by union. Rows are (baseline_letters, grant_letters, expected_letters). const Row = struct { baseline: []const u8, grant: []const u8, expected: []const u8 }; const rows = [_]Row{ .{ .baseline = "o", .grant = "i", .expected = "oi" }, // disjoint grant adds without clearing .{ .baseline = "oi", .grant = "o", .expected = "oi" }, // grant subset of baseline -> unchanged .{ .baseline = "og", .grant = "", .expected = "og" }, // empty grant leaves baseline exactly .{ .baseline = "", .grant = "x", .expected = "x" }, // empty baseline -> grant only .{ .baseline = "oigx", .grant = "oigx", .expected = "oigx" }, // idempotent full set }; for (rows) |row| { const base = capabilitySetFromLetters(row.baseline); const grant = capabilitySetFromLetters(row.grant); const got = establishedCapabilities(base, grant); const expected = capabilitySetFromLetters(row.expected); // (a) additive: every baseline bit survives the union (grant never clears one). var it = base.iterator(); while (it.next()) |cap| try std.testing.expect(got.contains(cap)); // (b) grant applied: the grant is a subset of the established set. try std.testing.expect(got.supersetOf(grant)); // (c) exactness: the established set equals the RFC-transcribed expectation. try std.testing.expect(got.eql(expected)); try std.testing.expectEqual(expected.count(), got.count()); } } test "sync §6.6: capabilitySetFromLetters on garbage never crashes and stays bounded" { // §6.6 L2155-2159: only the four assigned letters (o/i/g/x) name a // capability; arbitrary wire bytes (NUL and >0x7f legal, all []const u8) // must be skipped, so the derived set is always bounded by {o,i,g,x}. var prng = std.Random.DefaultPrng.init(0x6666_c0de); const rand = prng.random(); const full = capabilitySetFromLetters("oigx"); var buf: [64]u8 = undefined; for (0..4096) |_| { const len = rand.uintAtMost(usize, 64); // per-iteration length in 0..=64 rand.bytes(buf[0..len]); const set = capabilitySetFromLetters(buf[0..len]); try std.testing.expect(set.count() <= 4); // bounded by the four known caps try std.testing.expect(full.supersetOf(set)); // only known caps ever appear } } test "sync §7.1-r1: file card parses in both two- and four-argument forms" { const three = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " 42")); try std.testing.expectEqualStrings(test_hash_a, three.hash); try std.testing.expectEqual(@as(?[]const u8, null), three.deltasrc); try std.testing.expectEqual(@as(u32, 42), three.size); const four = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 42")); try std.testing.expectEqualStrings(test_hash_a, four.hash); try std.testing.expectEqualStrings(test_hash_b, four.deltasrc.?); try std.testing.expectEqual(@as(u32, 42), four.size); } test "sync §7.1-r2: file SIZE counts payload bytes; payload may contain 0x0a" { const body = "file " ++ test_hash_a ++ " 5\nAB\nCDtail"; const line = try readLine(body, 0); const card = try parseFileCard(tokenizeCard(line.bytes)); try std.testing.expectEqual(@as(u32, 5), card.size); const payload = try consumeContentPayload(body, line.next, card.size); try std.testing.expectEqualStrings("AB\nCD", payload.bytes); } test "sync §7.1-r3: file payload end located by count, not separator scan (rejection)" { // A naive LF-scan would stop at "AB" (2 bytes); byte-count framing // must instead consume the full 5-byte payload "AB\nCD" — the // embedded LF at offset 2 is not a terminator. const body = "file " ++ test_hash_a ++ " 5\nAB\nCD"; const line = try readLine(body, 0); const card = try parseFileCard(tokenizeCard(line.bytes)); const payload = try consumeContentPayload(body, line.next, card.size); try std.testing.expectEqualStrings("AB\nCD", payload.bytes); try std.testing.expect(payload.bytes.len != 2); } test "sync §7.1-r4: no trailing separator; next byte begins the next card" { const body = "file " ++ test_hash_a ++ " 3\nXYZgimme " ++ test_hash_b; const line = try readLine(body, 0); const card = try parseFileCard(tokenizeCard(line.bytes)); const payload = try consumeContentPayload(body, line.next, card.size); try std.testing.expectEqual(line.next + 3, payload.next); try std.testing.expectEqualStrings("gimme " ++ test_hash_b, body[payload.next..]); } test "sync §7.1-r5: two-arg form is full content, four-arg carries a delta basis" { const full = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " 10")); try std.testing.expectEqual(@as(?[]const u8, null), full.deltasrc); const delta_form = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10")); try std.testing.expectEqualStrings(test_hash_b, delta_form.deltasrc.?); } const a1_round2_file_cards = @embedFile("rfc_vectors/sync_a1_round2_file_cards.txt"); test "sync §7.1: worked example — first file card frames the 272-byte manifest (§A.1 L3003-3031)" { const body = a1_round2_file_cards; const line = try readLine(body, 0); const card = try parseFileCard(tokenizeCard(line.bytes)); try std.testing.expectEqualStrings("8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259", card.hash); try std.testing.expectEqual(@as(?[]const u8, null), card.deltasrc); // RFC L3029-3031: "seven lines of Fossil manifest notation, totalling // 272 bytes including terminating LFs" — 26+26+77+67+35+6+35 = 272. try std.testing.expectEqual(@as(u32, 272), card.size); const payload = try consumeContentPayload(body, line.next, card.size); try std.testing.expectEqualStrings( "C client:\\sadd\\salpha.txt\n" ++ "D 2026-07-02T18:30:46.860\n" ++ "F alpha.txt b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15\n" ++ "P c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57\n" ++ "R 6b3e7488d36fc39ea8093fdea87c3641\n" ++ "U drh\n" ++ "Z ed00cdc67449c3949587aa83b3fd8a67\n", payload.bytes, ); // No trailing separator: the very next byte starts the second file card. const next_line = try readLine(body, payload.next); try std.testing.expectEqualStrings( "file b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15 26", next_line.bytes, ); } test "sync §7.1: worked example — second file card frames the 26-byte payload (§A.1 L3011-3034)" { const body = a1_round2_file_cards; const first_line = try readLine(body, 0); const first_card = try parseFileCard(tokenizeCard(first_line.bytes)); const first_payload = try consumeContentPayload(body, first_line.next, first_card.size); const line = try readLine(body, first_payload.next); const card = try parseFileCard(tokenizeCard(line.bytes)); try std.testing.expectEqualStrings("b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15", card.hash); // RFC L3032-3034: "the text alpha content from client followed by a // single LF" — 25 text bytes + 1 LF = 26. try std.testing.expectEqual(@as(u32, 26), card.size); const payload = try consumeContentPayload(body, line.next, card.size); try std.testing.expectEqualStrings("alpha content from client\n", payload.bytes); // No trailing separator: the next byte starts the following gimme card. const next_line = try readLine(body, payload.next); try std.testing.expectEqualStrings( "gimme 4361252d0f450879dcd2e0b6a42b95ffe8a41d8e948130253ae8eefa7f4b79ea", next_line.bytes, ); } test "sync §7.2-r1: cfile card parses in both three- and four-value forms" { const three = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " 100 40")); try std.testing.expectEqualStrings(test_hash_a, three.hash); try std.testing.expectEqual(@as(?[]const u8, null), three.deltasrc); try std.testing.expectEqual(@as(u32, 100), three.uncompressed_size); try std.testing.expectEqual(@as(u32, 40), three.compressed_size); const four = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " 100 40")); try std.testing.expectEqualStrings(test_hash_b, four.deltasrc.?); try std.testing.expectEqual(@as(u32, 100), four.uncompressed_size); try std.testing.expectEqual(@as(u32, 40), four.compressed_size); } test "sync §7.2-r2: CSIZE counts payload bytes; USIZE is the reconstructed size" { const card = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " 1000 12")); try std.testing.expectEqual(@as(u32, 1000), card.uncompressed_size); try std.testing.expectEqual(@as(u32, 12), card.compressed_size); } test "sync §7.2-r3: payload framed by CSIZE, never USIZE (rejection)" { // USIZE (999999) vastly exceeds the body; a framer that mistakenly // used USIZE would overrun. `consumeContentPayload` sees only the // caller-selected size argument — passing CSIZE succeeds, passing // USIZE (proving what would happen if the wrong field reached the // framer) is Truncated. const body = "cfile " ++ test_hash_a ++ " 999999 5\nHELLOtail"; const line = try readLine(body, 0); const card = try parseCfileCard(tokenizeCard(line.bytes)); const payload = try consumeContentPayload(body, line.next, card.compressed_size); try std.testing.expectEqualStrings("HELLO", payload.bytes); try std.testing.expectError(ContentFrameError.Truncated, consumeContentPayload(body, line.next, card.uncompressed_size)); } test "sync §7.2-r4: no trailing separator after the cfile payload" { const body = "cfile " ++ test_hash_a ++ " 100 3\nXYZgimme " ++ test_hash_b; const line = try readLine(body, 0); const card = try parseCfileCard(tokenizeCard(line.bytes)); const payload = try consumeContentPayload(body, line.next, card.compressed_size); try std.testing.expectEqual(line.next + 3, payload.next); try std.testing.expectEqualStrings("gimme " ++ test_hash_b, body[payload.next..]); } test "sync §7.2-r5: USIZE parsed for syntax but not a frame boundary" { // USIZE (3) mismatches the 5 wire bytes CSIZE actually declares; the // card still parses and the payload still frames — current receivers // never cross-check USIZE against the received payload. const body = "cfile " ++ test_hash_a ++ " 3 5\nHELLOtail"; const line = try readLine(body, 0); const card = try parseCfileCard(tokenizeCard(line.bytes)); try std.testing.expectEqual(@as(u32, 3), card.uncompressed_size); const payload = try consumeContentPayload(body, line.next, card.compressed_size); try std.testing.expectEqual(@as(usize, 5), payload.bytes.len); } test "sync §7.2-r6: cfile payload framed verbatim; framer neither decompresses nor re-compresses" { // The payload is opaque stored-container bytes (a fabricated 4-byte // big-endian length header plus zlib-stream-shaped bytes, per §4.1's // container — the exact codec is out of scope here); the framer is a // byte-count slice, not a codec, so bytes in equal bytes out. const stored_container = [_]u8{ 0x00, 0x00, 0x00, 0x05, 0x78, 0x9c, 0x01, 0x02 }; const body = "cfile " ++ test_hash_a ++ " 5 8\n" ++ stored_container; const line = try readLine(body, 0); const card = try parseCfileCard(tokenizeCard(line.bytes)); const payload = try consumeContentPayload(body, line.next, card.compressed_size); try std.testing.expectEqualSlices(u8, &stored_container, payload.bytes); } test "sync §7.2-r7: x-fossil-uncompressed outer body means the payload is not double-compressed" { // §4.1: x-fossil-uncompressed's outer body is not zlib-wrapped // (outer_compressed == false); consumeContentPayload applies no // compression of its own, so the cfile payload it frames from that // outer body is exactly the stored-container bytes — never wrapped // a second time. try std.testing.expect(!media_props.get(.x_fossil_uncompressed).outer_compressed); const stored_container = [_]u8{ 0x00, 0x00, 0x00, 0x02, 0x78, 0x9c }; const body = "cfile " ++ test_hash_a ++ " 2 6\n" ++ stored_container; const line = try readLine(body, 0); const card = try parseCfileCard(tokenizeCard(line.bytes)); const payload = try consumeContentPayload(body, line.next, card.compressed_size); try std.testing.expectEqualSlices(u8, &stored_container, payload.bytes); } test "sync §7.1/§7.2: content payload whose span exceeds the body is Truncated (rejection)" { const file_body = "file " ++ test_hash_a ++ " 100\nshort"; const file_line = try readLine(file_body, 0); const file_card = try parseFileCard(tokenizeCard(file_line.bytes)); try std.testing.expectError(ContentFrameError.Truncated, consumeContentPayload(file_body, file_line.next, file_card.size)); const cfile_body = "cfile " ++ test_hash_a ++ " 5 100\nshort"; const cfile_line = try readLine(cfile_body, 0); const cfile_card = try parseCfileCard(tokenizeCard(cfile_line.bytes)); try std.testing.expectError(ContentFrameError.Truncated, consumeContentPayload(cfile_body, cfile_line.next, cfile_card.compressed_size)); } test "sync §7.1/§7.2: wrong arity or non-numeric size is Malformed (rejection)" { const file_cases = [_][]const u8{ "file " ++ test_hash_a, // arity 2: missing SIZE "file " ++ test_hash_a ++ " x", // non-numeric SIZE "file " ++ test_hash_a ++ " " ++ test_hash_b ++ " x", // non-numeric SIZE, four-arg "file " ++ test_hash_a ++ " 1 2 3", // arity 5: neither 3 nor 4 }; for (file_cases) |line| { try std.testing.expectError(CardParseError.Malformed, parseFileCard(tokenizeCard(line))); } const cfile_cases = [_][]const u8{ "cfile " ++ test_hash_a, // arity 2 "cfile " ++ test_hash_a ++ " 10", // arity 3: missing CSIZE "cfile " ++ test_hash_a ++ " x 10", // non-numeric USIZE "cfile " ++ test_hash_a ++ " 10 x", // non-numeric CSIZE "cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " x 10", // non-numeric USIZE, five-arg "cfile " ++ test_hash_a ++ " 1 2 3 4", // arity 6: neither 4 nor 5 }; for (cfile_cases) |line| { try std.testing.expectError(CardParseError.Malformed, parseCfileCard(tokenizeCard(line))); } } test "sync §7.4-r2: file card naming an unheld DELTASRC is accepted, not rejected" { const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10")); try std.testing.expectEqualStrings(test_hash_b, card.deltasrc.?); } test "sync §7.4-r2: cfile card naming an unheld DELTASRC is accepted, not rejected" { const card = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " 100 40")); try std.testing.expectEqualStrings(test_hash_b, card.deltasrc.?); } test "sync §7.4-r2: creating a phantom for the DELTASRC (store contract, §3.3)" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10")); const rid = try store.intern(card.deltasrc.?); try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid)); } test "sync §7.4-r1: delta against an unheld source is retained, not applied" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10")); const rid = try store.intern(card.deltasrc.?); // §7.4-r2 phantom, prerequisite for retention var queue = PendingDeltaQueue{}; 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); 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.?); 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); 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); try std.testing.expectEqual(@as(usize, 1), queue.itemCount()); try std.testing.expectEqualStrings(other_source, queue.items.items[0].deltasrc); const rid_a = try store.intern(test_hash_a); try std.testing.expectEqualStrings(target_a, store.content(rid_a).?); } fn completeForNoLeak(allocator: std.mem.Allocator) !void { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const source = "no-leak source bytes"; const target = "no-leak target bytes, a bit longer than the source"; const delta_bytes = try delta.create(std.testing.allocator, source, target); defer std.testing.allocator.free(delta_bytes); var queue = PendingDeltaQueue{}; 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); } 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); } 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, L3254 (public) and round-2 reply, // L3257 (private). const pub_card = try parseIgotCard(tokenizeCard("igot 23813573dea5ec687052a067866751f481e58ef4c478939ac601587a43f50b35")); try std.testing.expect(!pub_card.private); try std.testing.expectEqualStrings("23813573dea5ec687052a067866751f481e58ef4c478939ac601587a43f50b35", pub_card.hash); const priv_card = try parseIgotCard(tokenizeCard("igot 393953a9803595823fb560ddbe333767840a5ad89748a12fc2cd00f533c6df8a 1")); try std.testing.expect(priv_card.private); try std.testing.expectEqualStrings("393953a9803595823fb560ddbe333767840a5ad89748a12fc2cd00f533c6df8a", priv_card.hash); } test "sync §9.2: an igot flag other than 1 is malformed; wrong arity is malformed (rejection)" { const bad_flags = [_][]const u8{ "igot HASH 2", "igot HASH 0", "igot HASH x" }; for (bad_flags) |line| try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard(line))); try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard("igot"))); try std.testing.expectError(CardParseError.Malformed, parseIgotCard(tokenizeCard("igot HASH 1 extra"))); } test "sync §7.7 L1814-1816 r1: admission and integrity are distinct mechanisms (not conflated)" { // Under .sha1 policy a 40-hex name is admitted regardless of content — // admission never inspects bytes, so a corrupt payload still admits. var buf: [64]u8 = undefined; const good_content = "distinct-axes-content"; const sha1_name = artifact.artifactName(.sha1, good_content, &buf); var name_copy: [40]u8 = undefined; @memcpy(&name_copy, sha1_name); try std.testing.expectEqual(Admission.admit, admitInbound(.sha1, &name_copy)); try std.testing.expect(!artifact.verify(&name_copy, "corrupted-different-bytes")); // Under .shun_sha1 a valid SHA3 name both admits AND verifies — the two // decisions are separable, not fused into one predicate. var buf2: [64]u8 = undefined; const sha3_name = artifact.artifactName(.sha3_256, good_content, &buf2); var name3_copy: [64]u8 = undefined; @memcpy(&name3_copy, sha3_name); try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, &name3_copy)); try std.testing.expect(artifact.verify(&name3_copy, good_content)); } test "sync §7.7 L1820-1822 r2: shun-sha1 rejects an inbound 40-hex SHA1 name before storage (rejection)" { const sha1_name = "a" ** 40; const sha3_name = "a" ** 64; try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name)); try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, sha3_name)); } test "sync §7.7 L1822-1824 r3: the admission gate applies identically at file and cfile sites" { // One decision function, no card-type parameter: file and cfile see the // same rejection for the same (policy, name) pair. const sha1_name = "a" ** 40; const sites = [_]Operator{ .file, .cfile }; for (sites) |site| { _ = site; // the gate takes no card-type input — this loop documents that both sites route through it identically try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name)); } } test "sync §7.7 L1823-1824 r3b: a cfile on the clone 3 SEQNO path is admission-gated too (rejection)" { const sha1_name = "a" ** 40; // clone_seqno is the Operator naming the clone 3 SEQNO path; the cfile // card it carries is still subject to the same admission gate. _ = Operator.clone_seqno; try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name)); } test "sync §7.7 L1818-1820 distinction: admission is governed by the configured hash policy, not content" { const sha1_name = "a" ** 40; const admitting_policies = [_]artifact.HashPolicy{ .sha1, .auto, .sha3, .sha3_only }; for (admitting_policies) |p| { try std.testing.expectEqual(Admission.admit, admitInbound(p, sha1_name)); } try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, sha1_name)); } test "sync §7.7 L1826-1829 r5: integrity recomputes over reconstructed bytes, not the wire delta payload" { const source = "the quick brown fox jumps over the lazy dog, repeatedly and at length"; const target = "the quick brown fox jumps over the lazy dog, repeatedly and at length, plus more"; const d = try delta.create(std.testing.allocator, source, target); defer std.testing.allocator.free(d); var buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, target, &buf); var name_copy: [64]u8 = undefined; @memcpy(&name_copy, name); const reconstructed = try delta.apply(std.testing.allocator, source, d); defer std.testing.allocator.free(reconstructed); try std.testing.expect(artifact.verify(&name_copy, reconstructed)); try std.testing.expect(!artifact.verify(&name_copy, d)); // wire delta bytes, not reconstructed content } test "sync §7.7 L1829-1830 r6: pre-storage integrity on non-clone file cards; commit-time on all paths" { try std.testing.expectEqual(IntegritySchedule{ .pre_store = true, .at_commit = true }, integritySchedule(.file, false)); try std.testing.expect(!integritySchedule(.file, true).pre_store); const ops = [_]Operator{ .file, .cfile }; const paths = [_]bool{ false, true }; for (ops) |op| { for (paths) |clone| { try std.testing.expect(integritySchedule(op, clone).at_commit); } } } test "sync §7.7 L1830-1832 r7: cfile pre-storage integrity does not apply in transfer; commit-time still applies" { try std.testing.expectEqual(IntegritySchedule{ .pre_store = false, .at_commit = true }, integritySchedule(.cfile, false)); try std.testing.expectEqual(IntegritySchedule{ .pre_store = false, .at_commit = true }, integritySchedule(.cfile, true)); } test "sync §7.3: uvfile parse takes no hash-policy dependency" { // A HASH token that is neither 40 nor 64 lowercase-hex (not a legal // artifact-hash form) still parses: §7.3-r1 places uvfile outside // [FossilArtifact]'s hash-policy rules; only verifyUvContent (r13) // ever judges HASH against content. const card = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 NOT-A-HASH 0 4")); try std.testing.expectEqualStrings("NOT-A-HASH", card.hash); } test "sync §7.3: uvfile/uvigot/uvgimme card grammar fields" { // §7.3-r2: uvfile-card = "uvfile" SP fossilized SP decimal SP // uv-hash-field SP decimal SP decimal LF. const file = try parseUvfileCard(tokenizeCard("uvfile a.txt 1700000000 " ++ test_hash_a ++ " 12 0")); try std.testing.expectEqualStrings("a.txt", file.name); try std.testing.expectEqual(@as(i64, 1700000000), file.mtime); try std.testing.expectEqualStrings(test_hash_a, file.hash); try std.testing.expectEqual(@as(u32, 12), file.size); try std.testing.expectEqual(@as(u32, 0), file.flags); // uvigot-card = "uvigot" SP fossilized SP decimal SP uv-hash-field // SP decimal LF. const igot = try parseUvigotCard(tokenizeCard("uvigot a.txt 1700000000 " ++ test_hash_a ++ " 12")); try std.testing.expectEqualStrings("a.txt", igot.name); try std.testing.expectEqual(@as(i64, 1700000000), igot.mtime); try std.testing.expectEqualStrings(test_hash_a, igot.hash); try std.testing.expectEqual(@as(u32, 12), igot.size); // uvgimme-card = "uvgimme" SP fossilized LF. const gimme = try parseUvgimmeCard(tokenizeCard("uvgimme a.txt")); try std.testing.expectEqualStrings("a.txt", gimme.name); } test "sync §7.3: NAME is kept fossilize-escaped, not decoded" { // "a\sb" is the fossilize encoding of "a b" (§4.3, \s = SP); the // uvfile layer keeps the raw escaped token (r3) -- decoding is the // caller's job via artifact.decodeEscapes, not this parser's. const card = try parseUvfileCard(tokenizeCard("uvfile a\\sb 100 - 0 1")); try std.testing.expectEqualStrings("a\\sb", card.name); } test "sync §7.3: MTIME is decimal seconds since the epoch" { const card = try parseUvfileCard(tokenizeCard("uvfile a.txt 1700000000 - 0 1")); try std.testing.expectEqual(@as(i64, 1700000000), card.mtime); } test "sync §7.3: HASH is the content hash or literal -" { const with_hash = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 " ++ test_hash_a ++ " 0 0")); try std.testing.expectEqualStrings(test_hash_a, with_hash.hash); const deletion = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 - 0 1")); try std.testing.expectEqualStrings("-", deletion.hash); } test "sync §7.3: SIZE is the following payload byte count" { const body = "uvfile a.txt 100 " ++ test_hash_a ++ " 5 0\nhellotail"; const line = try readLine(body, 0); const card = try parseUvfileCard(tokenizeCard(line.bytes)); try std.testing.expectEqual(@as(u32, 5), card.size); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqualStrings("hello", payload.bytes); } test "sync §7.3 L1758-1759: deletion is decided by HASH == \"-\", not the 0x0001 bit" { const cases = [_]struct { line: []const u8, want: bool }{ // HASH="-", 0x0001 CLEAR: still a deletion -- HASH decides, not the bit. .{ .line = "uvfile a 100 - 0 0", .want = true }, // HASH="-", 0x0001 set (the sender-side metadata marker). .{ .line = "uvfile a 100 - 0 1", .want = true }, // real HASH, 0x0001 set: NOT a deletion -- the bit alone never decides. .{ .line = "uvfile a 100 " ++ test_hash_a ++ " 0 1", .want = false }, // ordinary content, no flags. .{ .line = "uvfile a 100 " ++ test_hash_a ++ " 42 0", .want = false }, }; for (cases) |c| { const card = try parseUvfileCard(tokenizeCard(c.line)); try std.testing.expectEqual(c.want, card.isDeletion()); } } test "sync §7.3 L1748: FLAGS bit 0x0004 marks omitted content" { const omit = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 " ++ test_hash_a ++ " 42 4")); try std.testing.expect(!omit.isDeletion()); try std.testing.expect(omit.contentOmitted()); const both = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 - 0 5")); try std.testing.expect(both.isDeletion()); try std.testing.expect(both.contentOmitted()); } test "sync §7.3: uvfile payload-presence keys on the 0x0001/0x0004 FLAGS bits, not the HASH" { // A real-HASH card carrying the 0x0001 bit is non-compliant, but the // receiver frames the payload from the FLAGS bits: payload follows iff // SIZE>0 and (FLAGS & 0x0005)==0 (the deletion and omitted-content bits). // 0x0001 is set here, so NO payload follows and the bytes after the line // are the next card — matching the reference receiver. Delete-vs-update is // a separate decision that still keys on HASH (isDeletion stays false). const card = try parseUvfileCard(tokenizeCard("uvfile f 100 " ++ test_hash_a ++ " 42 1")); try std.testing.expect(!card.isDeletion()); try std.testing.expect(!uvfilePayloadFollows(card)); const body = "uvfile f 100 " ++ test_hash_a ++ " 42 1\n" ++ "next card"; const line = try readLine(body, 0); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqual(@as(usize, 0), payload.bytes.len); try std.testing.expectEqual(line.next, payload.next); } test "sync §7.3: deletion uses HASH -, SIZE 0, deletion flag" { const card = try parseUvfileCard(tokenizeCard("uvfile f 100 - 0 1")); try std.testing.expectEqualStrings("-", card.hash); try std.testing.expectEqual(@as(u32, 0), card.size); try std.testing.expect(card.isDeletion()); try std.testing.expect(!uvfilePayloadFollows(card)); } test "sync §7.3: omitted-content transfer carries no payload despite SIZE" { const card = try parseUvfileCard(tokenizeCard("uvfile f 100 " ++ test_hash_a ++ " 42 4")); try std.testing.expect(!uvfilePayloadFollows(card)); const body = "uvfile f 100 " ++ test_hash_a ++ " 42 4\nnext line"; const line = try readLine(body, 0); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqual(@as(usize, 0), payload.bytes.len); try std.testing.expectEqual(line.next, payload.next); } test "sync §7.3: SIZE>0 no flags yields exactly SIZE payload bytes" { const body = "uvfile f 100 " ++ test_hash_a ++ " 5 0\nhelloXX"; const line = try readLine(body, 0); const card = try parseUvfileCard(tokenizeCard(line.bytes)); try std.testing.expect(uvfilePayloadFollows(card)); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqualStrings("hello", payload.bytes); } test "sync §7.3: byte after payload begins the next card line" { const body = "uvfile f 100 " ++ test_hash_a ++ " 5 0\nhellouvgimme x"; const line = try readLine(body, 0); const card = try parseUvfileCard(tokenizeCard(line.bytes)); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqual(line.next + 5, payload.next); try std.testing.expectEqualStrings("uvgimme x", body[payload.next..]); } test "sync §7.3: omitted content leaves next card immediately after LF" { const body = "uvfile f 100 - 0 5\nuvgimme x"; const line = try readLine(body, 0); const card = try parseUvfileCard(tokenizeCard(line.bytes)); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqual(@as(usize, 0), payload.bytes.len); try std.testing.expectEqual(line.next, payload.next); try std.testing.expectEqualStrings("uvgimme x", body[payload.next..]); } test "sync §7.3: HASH is verified against bytes before storing (rejection)" { // RFC 3174 §7.3 test vector: SHA-1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d. const sha1_abc = "a9993e364706816aba3e25717850c26c9cd0d89d"; try std.testing.expect(verifyUvContent(sha1_abc, "abc")); try std.testing.expect(!verifyUvContent(sha1_abc, "wrong content")); try std.testing.expect(!verifyUvContent("not-a-legal-length-hash", "abc")); } test "sync §7.3: omitted card updates metadata only when status permits" { // §9.1.1 status 2 (older local, same hash): a metadata-only touch // is permitted from an omitted-content card, since it never claims // to carry file bytes (r14). const local: UvRow = .{ .name = "f", .mtime = 100, .hash = "same" }; const newer_card: UvfileCard = .{ .name = "f", .mtime = 200, .hash = "same", .size = 42, .flags = uvfile_flag_omitted }; try std.testing.expectEqual(UvLanding.land, uvLandDecision(local, newer_card, .{}, true)); // Identical state: no update even though write-permitted. const identical_card: UvfileCard = .{ .name = "f", .mtime = 100, .hash = "same", .size = 42, .flags = uvfile_flag_omitted }; try std.testing.expectEqual(UvLanding.no_op, uvLandDecision(local, identical_card, .{}, true)); } test "sync §7.3: uvigot is the payload-free announcement form" { const card = try parseUvigotCard(tokenizeCard("uvigot f 100 " ++ test_hash_a ++ " 12")); try std.testing.expectEqualStrings("f", card.name); try std.testing.expectEqual(@as(i64, 100), card.mtime); try std.testing.expectEqualStrings(test_hash_a, card.hash); try std.testing.expectEqual(@as(u32, 12), card.size); } test "sync §7.3: a deletion is announced or transferred with HASH -" { const igot = try parseUvigotCard(tokenizeCard("uvigot f 100 - 0")); try std.testing.expectEqualStrings("-", igot.hash); const file = try parseUvfileCard(tokenizeCard("uvfile f 100 - 0 1")); try std.testing.expectEqualStrings("-", file.hash); } test "sync §7.3: uvgimme NAME requests a named unversioned file" { const card = try parseUvgimmeCard(tokenizeCard("uvgimme p/q%20r")); try std.testing.expectEqualStrings("p/q%20r", card.name); } test "sync §7.3: only a write-permitted receiver may land a uvfile (rejection)" { const card: UvfileCard = .{ .name = "f", .mtime = 200, .hash = "new", .size = 3, .flags = 0 }; try std.testing.expectEqual(UvLanding.not_authorized, uvLandDecision(null, card, .{}, false)); try std.testing.expectEqual(UvLanding.land, uvLandDecision(null, card, .{}, true)); } test "sync §7.3: an accepted change clears the cached catalog hash" { try std.testing.expect(uvLandingClearsCatalog(.land)); try std.testing.expect(!uvLandingClearsCatalog(.no_op)); try std.testing.expect(!uvLandingClearsCatalog(.not_authorized)); } test "sync §7.3: under-arity uvfile card is malformed" { try std.testing.expectError(CardParseError.Malformed, parseUvfileCard(tokenizeCard("uvfile f 100 - 0"))); } test "sync §5.2/§7.3: uvfile ignores visible tokens after its six parser slots" { // §5.2 L1324 / §7.3-r2 — `uvfile` consumes the fixed six-slot parser // window. Visible tokens beyond FLAGS do not make a valid prefix malformed. const card = try parseUvfileCard(tokenizeCard("uvfile n 1 - 0 0 ignored after flags")); try std.testing.expectEqualStrings("n", card.name); try std.testing.expectEqual(@as(i64, 1), card.mtime); try std.testing.expectEqualStrings("-", card.hash); try std.testing.expectEqual(@as(u32, 0), card.size); try std.testing.expectEqual(@as(u32, 0), card.flags); } test "sync §7.3: non-numeric MTIME/SIZE/FLAGS is malformed" { const cases = [_][]const u8{ "uvfile f x - 0 0", // non-numeric MTIME "uvfile f 100 - x 0", // non-numeric SIZE "uvfile f 100 - 0 x", // non-numeric FLAGS }; for (cases) |line| { try std.testing.expectError(CardParseError.Malformed, parseUvfileCard(tokenizeCard(line))); } } test "sync §7.3: uvigot wrong arity is malformed" { try std.testing.expectError(CardParseError.Malformed, parseUvigotCard(tokenizeCard("uvigot f 100 -"))); try std.testing.expectError(CardParseError.Malformed, parseUvigotCard(tokenizeCard("uvigot f 100 - 0 extra"))); } test "sync §7.3: uvgimme wrong arity is malformed" { try std.testing.expectError(CardParseError.Malformed, parseUvgimmeCard(tokenizeCard("uvgimme"))); try std.testing.expectError(CardParseError.Malformed, parseUvgimmeCard(tokenizeCard("uvgimme f extra"))); } test "sync §7.3/§7.5 receive: uvigot and uvgimme preserve a real private marker for later content" { const gpa = std.testing.allocator; // A real private card, not seeded internal state, must remain pending // across a valid but unsupported uvigot and bind the subsequent file. { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), "private\nuvigot a\\sb 1 - 0\n"), ); try std.testing.expect(client.private_pending); try std.testing.expectEqual(ClientState.exchange, client.state); const obs = try client.consumeReply(gpa, ms.store(), "file " ++ test_hash_a ++ " 5\nHELLO"); try std.testing.expect(obs.private_content_discarded); try std.testing.expect(!obs.private_content_delivered); try std.testing.expect(ms.store().lookup(test_hash_a) == null); try std.testing.expect(!client.private_pending); } // The same marker survives uvgimme and binds a later cfile. The invalid // container proves the private discard path is observable before decoding. { const garbage = "not a fossil zlib container at all"; const cfile = try buildCfileBody(gpa, test_hash_a, 99, garbage.len, garbage); defer gpa.free(cfile); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), "private\nuvgimme a\\sb\n"), ); try std.testing.expect(client.private_pending); try std.testing.expectEqual(ClientState.exchange, client.state); const obs = try client.consumeReply(gpa, ms.store(), cfile); try std.testing.expect(obs.private_content_discarded); try std.testing.expect(!obs.private_content_delivered); try std.testing.expect(ms.store().lookup(test_hash_a) == null); try std.testing.expect(!client.private_pending); } } test "sync §7.3 receive: uvigot deletion with nonzero SIZE aborts before unsupported boundary" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), "uvigot f 1 - 1\n"), ); try std.testing.expectEqual(ClientState.aborted, client.state); } test "sync §7.3 receive: private followed by uvfile reaches the unsupported boundary and leaves the marker pending" { const gpa = std.testing.allocator; const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n"), ); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expect(client.private_pending); try std.testing.expect(ms.store().lookup(empty_hash) == null); } test "sync §7.3 receive: non-ABNF numeric spellings abort before unsupported boundary" { const gpa = std.testing.allocator; const cases = [_][]const u8{ "uvfile f +1 - 0 0\n", // MTIME "uvfile f 1 - -0 0\n", // SIZE "uvfile f 1 - 0 +0\n", // FLAGS "uvfile f 1 - 0 1_0\n", // FLAGS "uvigot f +1 - 0\n", // MTIME "uvigot f 1 - -0\n", // SIZE }; for (cases) |body| { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); } } test "sync §7.3 receive: oversized FLAGS preserve omitted-content framing" { const gpa = std.testing.allocator; const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; // 2^128 + 4 is an arbitrary-length decimal whose defined low bits are // 0x0004. Despite the nonzero SIZE, omitted content leaves no payload to // frame, so a valid uvfile reaches the deliberate unsupported boundary. { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), "uvfile f 1 " ++ empty_hash ++ " 1 340282366920938463463374607431768211460\n"), ); try std.testing.expectEqual(ClientState.exchange, client.state); } // 2^128 has no defined low bit. With the same nonzero SIZE and no // payload, framing must reject it as truncated before the unsupported // boundary; accepting an oversized token alone is insufficient. { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), "uvfile f 1 " ++ empty_hash ++ " 1 340282366920938463463374607431768211456\n"), ); try std.testing.expectEqual(ClientState.aborted, client.state); } } test "sync §7.3 receive: uvfile frames content and accepts omitted reserved and extra fields" { const gpa = std.testing.allocator; const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; const abc_hash = "a9993e364706816aba3e25717850c26c9cd0d89d"; const cases = [_][]const u8{ "uvfile f 1 " ++ abc_hash ++ " 3 0\nabc", "uvfile f 1 - 0 0\n", "uvfile f 1 " ++ abc_hash ++ " 3 4\n", "uvfile f 1 " ++ abc_hash ++ " 3 8\nabc", "uvfile f 1 " ++ empty_hash ++ " 0 0 ignored after flags\n", }; for (cases) |body| { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), body)); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expectEqual(@as(usize, 0), client.send_queue.count()); try std.testing.expect(ms.store().lookup(abc_hash) == null); } } test "sync §7.3 receive: malformed UV cards abort before the unsupported boundary" { const gpa = std.testing.allocator; const abc_hash = "a9993e364706816aba3e25717850c26c9cd0d89d"; const cases = [_][]const u8{ "uvfile f nope " ++ abc_hash ++ " 0 0\n", "uvfile f 1 not-a-hash 0 0\n", "uvigot f 1 not-a-hash 0\n", "uvfile f 1 - 3 4\n", "uvfile f 1 " ++ abc_hash ++ " 4 0\nabc", "uvfile f 1 " ++ abc_hash ++ " 3 0\nxyz", "uvfile f 1 " ++ abc_hash ++ " 4294967296 0\n", "uvigot f 1 - 0 extra\n", "uvgimme f extra\n", }; for (cases) |body| { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(gpa, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); } } test "sync §7.3/§4.3 receive: a non-canonically fossilized NAME is not rejected" { // §7.3 L2253 — NAME is "encoded using the fossilize character-escaping // scheme"; the section enumerates its receiver rejections (negative // FLAGS, HASH - with SIZE>0, missing write permission) and licenses NONE // for a non-canonical NAME encoding. §4.3 L657-663 makes the defossilize // decoder error-free on an unrecognized escape (\q -> q, no error), and // §5.1 L1723-1724 requires decode-before-use, not a canonical-form check. // `bad\q` is therefore a valid NAME and must reach the unsupported // boundary, not abort. const gpa = std.testing.allocator; const cases = [_][]const u8{ "uvgimme bad\\q\n", "uvfile bad\\q 1 - 0 0\n", "uvigot bad\\q 1 - 0\n", }; for (cases) |body| { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); try std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), body)); try std.testing.expectEqual(ClientState.exchange, client.state); } } test "sync §9.1.1: send table init loads non-null-hash rows, mtimeOnly false, catalog-sent false" { const rows = [_]UvRow{ .{ .name = "a.txt", .mtime = 1, .hash = "h1" }, .{ .name = "b.txt", .mtime = 2, .hash = null }, }; var table = try UvSendTable.initFromLocal(std.testing.allocator, &rows); defer table.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), table.count()); try std.testing.expectEqual(@as(?bool, false), table.mtimeOnly("a.txt")); try std.testing.expect(!table.contains("b.txt")); const s: UvSession = .{}; try std.testing.expectEqual(false, s.hash_sent); } test "sync §9.1.1: catalog hash of an empty catalog is SHA-1(\"\")" { const hash = uvCatalogHash(&.{}); try std.testing.expectEqualStrings("da39a3ee5e6b4b0d3255bfef95601890afd80709", &hash); } test "sync §9.1.1: null-hash and deleted rows are excluded from the catalog hash" { const deleted_only = [_]UvRow{ .{ .name = "a.txt", .mtime = 1, .hash = null }, .{ .name = "b.txt", .mtime = 2, .hash = null }, }; const h1 = uvCatalogHash(&deleted_only); try std.testing.expectEqualStrings("da39a3ee5e6b4b0d3255bfef95601890afd80709", &h1); const with_one_live = [_]UvRow{ .{ .name = "a.txt", .mtime = 1, .hash = null }, .{ .name = "b.txt", .mtime = 2, .hash = "h2" }, }; const h2 = uvCatalogHash(&with_one_live); try std.testing.expect(!std.mem.eql(u8, &h1, &h2)); } test "sync §9.1.1: server suppresses duplicate catalog emission within one request" { var s: ServerUvRequest = .{}; try std.testing.expectEqual(UvResponse.push_ok, serverUvCatalog(&s, false, true, true)); try std.testing.expectEqual(UvResponse.none, serverUvCatalog(&s, false, true, true)); var s2: ServerUvRequest = .{}; try std.testing.expectEqual(UvResponse.push_ok, serverUvCatalog(&s2, false, true, true)); } test "sync §9.1.1: status 0 when no local row exists" { try std.testing.expectEqual(UvStatus.absent, uvStatus(null, 100, "abc")); } test "sync §9.1.1: LOCAL-HASH is - for a null local hash; HASH-CMP compares it bytewise" { const row: UvRow = .{ .name = "a", .mtime = 100, .hash = null }; try std.testing.expectEqual(UvStatus.identical, uvStatus(row, 100, "-")); try std.testing.expectEqual(UvStatus.remote_wins, uvStatus(row, 100, "abc")); } test "sync §9.1.1: HASH-CMP 0 yields status 3 + MTIME-CMP" { const Case = struct { local_mtime: i64, remote_mtime: i64, want: UvStatus }; const cases = [_]Case{ .{ .local_mtime = 100, .remote_mtime = 200, .want = .older }, .{ .local_mtime = 150, .remote_mtime = 150, .want = .identical }, .{ .local_mtime = 300, .remote_mtime = 200, .want = .newer }, }; for (cases) |c| { const row: UvRow = .{ .name = "a", .mtime = c.local_mtime, .hash = "same-hash" }; try std.testing.expectEqual(c.want, uvStatus(row, c.remote_mtime, "same-hash")); } } test "sync §9.1.1: status 1 when MTIME-CMP<0, or MTIME-CMP 0 and HASH-CMP<0" { const older: UvRow = .{ .name = "a", .mtime = 100, .hash = "aaa" }; try std.testing.expectEqual(UvStatus.remote_wins, uvStatus(older, 200, "zzz")); const tie: UvRow = .{ .name = "a", .mtime = 100, .hash = "aaa" }; try std.testing.expectEqual(UvStatus.remote_wins, uvStatus(tie, 100, "zzz")); } test "sync §9.1.1: status 5 otherwise (local content or deletion overrides)" { const newer: UvRow = .{ .name = "a", .mtime = 300, .hash = "zzz" }; try std.testing.expectEqual(UvStatus.local_wins, uvStatus(newer, 200, "aaa")); const tie: UvRow = .{ .name = "a", .mtime = 100, .hash = "zzz" }; try std.testing.expectEqual(UvStatus.local_wins, uvStatus(tie, 100, "aaa")); } test "sync §9.1.1: revert mode maps status 4 to 2 and status 5 to 1" { const newer: UvRow = .{ .name = "a", .mtime = 300, .hash = "same" }; const d4 = uvDecide(newer, 200, "same", .{ .revert = true }, false); try std.testing.expectEqual(UvStatus.older, d4.status); const local_wins: UvRow = .{ .name = "a", .mtime = 300, .hash = "zzz" }; const d5 = uvDecide(local_wins, 200, "aaa", .{ .revert = true }, false); try std.testing.expectEqual(UvStatus.remote_wins, d5.status); } test "sync §9.1.1: dry-run suppresses local changes and reply messages" { const absent = uvDecide(null, 100, "abc", .{ .dry_run = true }, false); try std.testing.expectEqual(UvLocalAction.none, absent.local); const remote_row: UvRow = .{ .name = "a", .mtime = 100, .hash = "aaa" }; const wins = uvDecide(remote_row, 200, "zzz", .{ .dry_run = true }, false); try std.testing.expectEqual(UvLocalAction.none, wins.local); const older_row: UvRow = .{ .name = "a", .mtime = 100, .hash = "same" }; const o = uvDecide(older_row, 200, "same", .{ .dry_run = true }, false); try std.testing.expectEqual(UvLocalAction.none, o.local); } test "sync §9.1.1: send-table apply removes, or sets mtimeOnly true/false" { var table: UvSendTable = .{}; defer table.deinit(std.testing.allocator); try table.apply(std.testing.allocator, "a.txt", .mtime_only); try std.testing.expectEqual(@as(?bool, true), table.mtimeOnly("a.txt")); try table.apply(std.testing.allocator, "a.txt", .keep_full); try std.testing.expectEqual(@as(?bool, false), table.mtimeOnly("a.txt")); try table.apply(std.testing.allocator, "a.txt", .remove); try std.testing.expect(!table.contains("a.txt")); try std.testing.expect(table.isEmpty()); } fn uvSendTableNoLeak(gpa: std.mem.Allocator) !void { const rows = [_]UvRow{ .{ .name = "a.txt", .mtime = 1, .hash = "h1" }, .{ .name = "b.txt", .mtime = 2, .hash = null }, }; var table = try UvSendTable.initFromLocal(gpa, &rows); defer table.deinit(gpa); try table.apply(gpa, "a.txt", .mtime_only); try table.apply(gpa, "c.txt", .keep_full); try table.apply(gpa, "a.txt", .remove); } test "sync §9.1.1: send table init and apply survive allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, uvSendTableNoLeak, .{}); } test "sync §9.1.1: uvgimme answered uvfile with content else uvigot" { try std.testing.expectEqual(UvGimmeReply.uvfile, uvGimmeReply(true)); try std.testing.expectEqual(UvGimmeReply.uvigot, uvGimmeReply(false)); } test "sync §9.1.1: uvfile landing rechecks the same status function" { // remote wins (status 0, absent locally) + write-permitted -> land. const absent_card: UvfileCard = .{ .name = "f", .mtime = 100, .hash = test_hash_a, .size = 3, .flags = 0 }; try std.testing.expectEqual(UvLanding.land, uvLandDecision(null, absent_card, .{}, true)); // local wins (status 5) -> no_op even though write-permitted. const local: UvRow = .{ .name = "f", .mtime = 300, .hash = "zzz" }; const local_wins_card: UvfileCard = .{ .name = "f", .mtime = 200, .hash = "aaa", .size = 3, .flags = 0 }; try std.testing.expectEqual(UvLanding.no_op, uvLandDecision(local, local_wins_card, .{}, true)); // write not permitted -> not_authorized regardless of status. try std.testing.expectEqual(UvLanding.not_authorized, uvLandDecision(null, absent_card, .{}, false)); } test "sync §9.2.1 r1: sync defines no branch/tag/leaf/closed/trunk wire operator" { // L2285-2287: "The sync protocol MUST NOT treat an igot, gimme, file, or // cfile card as an independent branch/tag operation, and it does not // define separate wire cards for those views." const view_tokens = [_][]const u8{ "branch", "tag", "leaf", "closed", "trunk" }; for (view_tokens) |token| { try std.testing.expectEqual(@as(?Operator, null), parseOperator(token)); } const carrier_tokens = [_][]const u8{ "igot", "gimme", "file", "cfile" }; const carrier_ops = [_]Operator{ .igot, .gimme, .file, .cfile }; for (carrier_tokens, carrier_ops) |token, want| { try std.testing.expectEqual(@as(?Operator, want), parseOperator(token)); } } test "sync §9.2.1 r2: a view answer requires the carrying artifact to be crosslinked (stored)" { // L2307-2309: "may rely on ... answers for the synchronized scope only // after the artifacts that carry the relevant structural cards have // been stored." var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); try std.testing.expectError(error.NotFound, rs.currentTrunk("trunk")); try std.testing.expectError(error.NotFound, rs.resolveName("trunk")); const rid = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); try std.testing.expectEqual(rid, try rs.currentTrunk("trunk")); } test "sync §9.2.1 r3: a delta base retained as a pending phantom does not error the view" { // L2310-2311: "required delta bases have been resolved or retained as // pending phantoms." var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); const store = ms.store(); // test_hash_a (the primary parent / delta base) is never crosslinked or // stored — it stays a pending phantom. const child = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 5, .parents = &.{test_hash_a}, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); const parent_rid = try store.intern(test_hash_a); try std.testing.expectEqual(@as(?[]const u8, null), store.content(parent_rid)); try std.testing.expectEqual(child, try rs.currentTrunk("trunk")); } test "sync §9.2.1 r4: storing artifact bytes without crosslink processing yields no view" { // L2311-2312: "and repository crosslink/tag-state processing has run // for the stored artifacts." Bytes stored (r2 satisfied) but crosslink // never called: still no view — necessity witness distinct from r2. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); const store = ms.store(); _ = try store.put(test_hash_a, "manifest bytes, never crosslinked"); try std.testing.expectError(error.NotFound, rs.resolveName("trunk")); try std.testing.expectError(error.NotFound, rs.currentTrunk("trunk")); const rid = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); try std.testing.expectEqual(rid, try rs.currentTrunk("trunk")); } test "sync §9.2.1 r5: reliable view holds only when stored, base-settled, and crosslink all hold" { // L2307-2312, the full conjunction: the single case where all three // preconditions hold is the meaningful, positive-space view. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); const child = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 5, .parents = &.{test_hash_a}, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); try std.testing.expect(rs.isLeaf(child)); try std.testing.expectEqual(child, try rs.currentTrunk("trunk")); try std.testing.expectEqual(try rs.resolveName("trunk"), try rs.currentTrunk("trunk")); } test "sync §9.2.1 r6: an announced-only hash proves no final leaf/closed/trunk view (rejection)" { // L2312-2314: "Before that point, the wire stream only proves that the // peer announced or sent artifact hashes; it does not prove a final // open-leaf, leaf, closed-marker, or current-trunk view." const test_hash_c = "c" ** 40; var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); const store = ms.store(); const rid = try store.intern(test_hash_c); try std.testing.expect(!rs.isLeaf(rid)); try std.testing.expect(!rs.isOpenLeaf(rid)); try std.testing.expectError(error.NotFound, rs.resolveName("trunk")); } test "sync §9.2.1 r7: each peer derives current-trunk from its own main-branch setting" { // L2300-2303: "the protocol transports only the artifact inputs; each // peer derives current-trunk locally, using its own main-branch // setting." var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); _ = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); _ = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 2, .tags = &.{ .{ .type = .propagate, .name = "sym-release", .target = null, .value = "release" }, } }); try std.testing.expectEqual(try rs.resolveName("trunk"), try rs.currentTrunk("trunk")); try std.testing.expectEqual(try rs.resolveName("release"), try rs.currentTrunk("release")); } test "sync §9.2.1 r8: two main-branch settings resolve different current-trunks (worked example)" { // L2303-2305: "Current-trunk is therefore not a pure wire-synchronized // value: two synchronized peers can resolve different current-trunks // when their main-branch settings differ." var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); _ = try rs.crosslink(.{ .hash = test_hash_a, .mtime = 1, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); _ = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 2, .tags = &.{ .{ .type = .propagate, .name = "sym-release", .target = null, .value = "release" }, } }); try std.testing.expect(try rs.currentTrunk("trunk") != try rs.currentTrunk("release")); } test "sync §9.2.1 r9: an incomplete-closure view is not a sync error and does not force a round (rejection)" { // L2316-2322: "Later receipt of missing artifacts can change ... // current-trunk answers ... This is not a separate sync error; it // follows from the normal phantom termination rule in Section 3.7." var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var rs = repostate.RepoState.init(std.testing.allocator, ms.store()); defer rs.deinit(); // test_hash_a (the primary parent) stays a pending phantom, exactly as // in r3 — the view is computed over the closure available so far. _ = try rs.crosslink(.{ .hash = test_hash_b, .mtime = 5, .parents = &.{test_hash_a}, .tags = &.{ .{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" }, } }); _ = try rs.currentTrunk("trunk"); // resolves without a sync-layer error const obs = RoundObservation{ .operation = .pull, .phantom_table_nonempty = true }; try std.testing.expect(!continueExchange(obs)); } const elig_clear = Eligibility{ .peer_lacks = false, .shunned = false, .private = false, .private_sync_enabled = false }; test "sync §9.3: igot for an unheld obtainable artifact creates a phantom" { // §3.3 item 1, 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 item 2, L626-628 — a file or cfile whose DELTASRC is not held // creates a phantom for that source; interning it leaves content absent. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const fc = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 10")); try std.testing.expectEqualStrings(test_hash_b, fc.deltasrc.?); const frid = try store.intern(fc.deltasrc.?); try std.testing.expectEqual(@as(?[]const u8, null), store.content(frid)); const cc = try parseCfileCard(tokenizeCard("cfile " ++ test_hash_a ++ " " ++ test_hash_b ++ " 100 40")); try std.testing.expectEqualStrings(test_hash_b, cc.deltasrc.?); const crid = try store.intern(cc.deltasrc.?); try std.testing.expectEqual(@as(?[]const u8, null), store.content(crid)); } test "sync §9.3: an igot-created phantom is gimme-eligible under default filters" { // §3.3 L641-643 — "the receiver MUST make that phantom eligible for // gimme emission"; with no filter set it is eligible. try std.testing.expectEqual(IgotDisposition.create_phantom, igotDisposition(.client, .pull, false)); try std.testing.expect(gimmeEligible(elig_clear)); } test "sync §9.3: a phantom is deleted when its content is stored" { // §3.3 L664-666 — the normal-path atomic upgrade: storing content // removes the phantom (content(rid) transitions null -> present). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const rid = try store.intern(test_hash_a); try std.testing.expectEqual(@as(?[]const u8, null), store.content(rid)); _ = try store.put(test_hash_a, "stored content"); try std.testing.expect(store.content(rid) != null); } test "sync §9.3: dangling-delta HASH is content-received and not re-requested while DELTASRC is pending" { // §3.3 L664-680 — the dangling-delta path: "the delta payload for HASH // is stored as received, the phantom record for HASH is deleted, and a // phantom record is created for DELTASRC ... The content-received flag is // set in both paths." OPTION B derives content-received at the sync layer // (hasTarget), so HASH drops out of gimme while its DELTASRC stays a // genuine phantom and is re-requested. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const src_bytes = "the DELTASRC artifact's eventual stored content"; const hash_bytes = "the dangling target, reconstructed from the basis, a bit longer"; const delta_bytes = try delta.create(std.testing.allocator, src_bytes, hash_bytes); defer std.testing.allocator.free(delta_bytes); // HASH and SRC both arrive as phantoms; the SRC-basis delta is retained. _ = try store.intern(test_hash_a); // HASH phantom, deleted below on receipt _ = try store.intern(test_hash_b); // DELTASRC phantom (§3.3 item 2) var queue = PendingDeltaQueue{}; 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); // Post-basis: the queue entry is gone AND HASH now holds reconstructed // content, so both halves of the disjunction still exclude it from gimme. try std.testing.expect(!queue.hasTarget(test_hash_a)); const hash_rid = try store.intern(test_hash_a); try std.testing.expect(store.content(hash_rid) != null); try std.testing.expectEqualStrings(hash_bytes, store.content(hash_rid).?); try std.testing.expect(!(store.content(hash_rid) == null and !queue.hasTarget(test_hash_a) and gimmeEligible(elig_clear))); } test "sync §9.3: a strategy requests missing content only via igot+gimme (single rule surface)" { // §9.3 L2328-2331 — the only path to request missing content: igot // creates the phantom, gimme requests it under the §3.3 filters. No // other operator originates a content request. try std.testing.expectEqual(IgotDisposition.create_phantom, igotDisposition(.client, .pull, false)); try std.testing.expect(gimmeEligible(elig_clear)); // would emit gimme HASH // The inbound mirror governs the reply; held+sendable+disclosable sends. try std.testing.expectEqual(GimmeResponse.send, gimmeResponse(true, true, true)); } test "sync §9.4: the per-round peer-lacks, shun, and private filters are applied" { // §9.4 L2334-2337 + §3.3 (a)/(b)/(c), L646-652 — each filter alone // excludes; all-clear is eligible. const Case = struct { e: Eligibility, want: bool }; const cases = [_]Case{ .{ .e = elig_clear, .want = true }, .{ .e = .{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false }, .want = false }, .{ .e = .{ .peer_lacks = false, .shunned = true, .private = false, .private_sync_enabled = false }, .want = false }, .{ .e = .{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false }, .want = false }, .{ .e = .{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true }, .want = true }, }; for (cases) |c| try std.testing.expectEqual(c.want, gimmeEligible(c.e)); } test "sync §9.4: per-round filters are not persisted across rounds" { // §9.4 L2337-2338 + §3.1 r20/r21 — a round boundary resets both tables. var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerLacks(test_hash_a); try rt.recordPeerHas(test_hash_b); rt.reset(); try std.testing.expect(!rt.peerLacks(test_hash_a)); try std.testing.expect(!rt.peerHas(test_hash_b)); } test "sync §9.4: peer-lacks hashes are excluded from gimme (rejection)" { // §3.3 filter (a), L647. try std.testing.expect(!gimmeEligible(.{ .peer_lacks = true, .shunned = false, .private = false, .private_sync_enabled = false })); } test "sync §9.4: shunned hashes are excluded from gimme (rejection)" { // §3.3 filter (b), L649. try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = true, .private = false, .private_sync_enabled = false })); } test "sync §9.4: private hashes excluded unless private sync enabled (rejection)" { // §3.3 filter (c), L651-652. try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false })); try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true })); } test "sync §9.4: the eligibility filter is evaluated freshly each round" { // §3.3 L644-645 — "evaluated freshly each round." Record peer-lacks(H) // this round -> excluded; reset the round -> eligible again. var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerLacks(test_hash_a); try std.testing.expect(!gimmeEligible(.{ .peer_lacks = rt.peerLacks(test_hash_a), .shunned = false, .private = false, .private_sync_enabled = false })); rt.reset(); try std.testing.expect(!rt.peerLacks(test_hash_a)); try std.testing.expect(gimmeEligible(.{ .peer_lacks = rt.peerLacks(test_hash_a), .shunned = false, .private = false, .private_sync_enabled = false })); } test "sync §9.4: receiving gimme records the peer lacks the hash" { // §3.3 L654-655 — a receiver that gets gimme HASH records peer-lacks HASH. var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerLacks(test_hash_a); try std.testing.expect(rt.peerLacks(test_hash_a)); try std.testing.expect(!rt.peerLacks(test_hash_b)); } test "sync §9.4: gimme for unheld content is silently skipped (rejection)" { // §3.3 L661-662 — unheld gimme is a silent skip, no error card. try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, true, true)); try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(false, false, false)); } test "sync §9.4: gimme for held-but-unsendable content emits nothing, no defer (rejection)" { // §3.3 L658-660 — held but the direction forbids sending: no content, no // error card, no deferred send. try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, true)); try std.testing.expectEqual(GimmeResponse.silent_skip, gimmeResponse(true, false, false)); } test "sync §9.5: loop termination follows §3.7 including clone continuation (e)" { // §9.5 L2341-2344 — termination is the §3.7 rule, clone included. const Case = struct { obs: RoundObservation, want: bool }; const cases = [_]Case{ .{ .obs = .{ .operation = .clone, .first_clone_round = true }, .want = true }, // (e) .{ .obs = .{ .operation = .clone, .artifact_received_this_round = true }, .want = true }, // (e) .{ .obs = .{ .operation = .clone, .clone_seqno_next = 5, .artifact_count_grew = true }, .want = true }, // (e) .{ .obs = .{ .operation = .clone }, .want = false }, // clone, none of (e) -> stop .{ .obs = .{ .operation = .pull, .versioned_content_delivered = true, .phantom_table_nonempty = true }, .want = true }, // (a) }; for (cases) |c| try std.testing.expectEqual(c.want, continueExchange(c.obs)); } test "sync §9.5: termination reads the per-round content-received flag, not per-artifact phantom state (rejection)" { // §3.7 L775-778 + §9.5 — the client MUST NOT continue solely because // phantoms remain. Continuation is driven by the per-round content- // received flag (`versioned_content_delivered`, set by BOTH the normal // and the dangling-delta path per §3.3 L677-680), never per-artifact // store state. `continueExchange` takes only a `RoundObservation`, so it // structurally cannot consult the phantom set. const stalled = RoundObservation{ .operation = .pull, .phantom_table_nonempty = true }; try std.testing.expect(!continueExchange(stalled)); // unresolved phantoms alone -> stop const progressed = RoundObservation{ .operation = .pull, .versioned_content_delivered = true, .phantom_table_nonempty = true }; try std.testing.expect(continueExchange(progressed)); // (a) via the per-round flag } test "sync §9.5: the server signals no completion and holds no per-session termination state" { // §9.5 L2344-2347 + §3.7 L789-791 — the reply's closing comment carries // only a timestamp and an error count; termination is decided solely by // the client's `continueExchange` over its own per-round observation. const tc = parseTimestampComment("# timestamp 2026-07-10T00:00:00 errors 0").?; try std.testing.expectEqualStrings("2026-07-10T00:00:00", tc.datetime); try std.testing.expectEqual(@as(u64, 0), tc.errors); // TimestampComment carries exactly {datetime, errors} — no completion field. try std.testing.expectEqual(@as(usize, 2), @typeInfo(TimestampComment).@"struct".fields.len); // With no pending work the client stops on its own, no server input. try std.testing.expect(!continueExchange(.{ .operation = .sync })); } // --------------------------------------------------------------------------- // Section 3.2 — Reply consumption (Client.consumeReply); Appendix A worked // exchanges A.1 (push/pull convergence), A.2 (SHA3 gate error card), A.4 // (comment lines); §7.5 (private-marker state machine, r1-r8). // --------------------------------------------------------------------------- // §A.1 L3085-3090 — Round 1 reply, 367-byte body: server-version pragma, two // gimme cards, two igot cards, closing timestamp. The vector holds raw wire // bytes; the citation lives here, never inside the .txt. const a1_round1_reply = @embedFile("rfc_vectors/sync_a1_round1_reply.txt"); // §A.1 L3141-3152 — Round 2 reply, 533-byte body: server-version pragma, two // full-content file cards (270- and 31-byte payloads), closing timestamp. const a1_round2_reply = @embedFile("rfc_vectors/sync_a1_round2_reply.txt"); // §A.2 L3206-3209 — SHA3 gate response, 191-byte body: error, clone_seqno 0, // push, closing timestamp. const a2_reply = @embedFile("rfc_vectors/sync_a2_reply.txt"); comptime { // The RFC states these body sizes directly (L3074, L3126, L3204); a // transcription drift trips here before any test runs. std.debug.assert(a1_round1_reply.len == 367); std.debug.assert(a1_round2_reply.len == 533); std.debug.assert(a2_reply.len == 191); } // §A.1 hashes: server gimme -> peer-lacks (L3086-3087); the ancestor the // server already holds (L3092). const a1_gimme_1 = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259"; const a1_gimme_2 = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15"; const a1_held_ancestor = "c5b2e0ecb779a7616b32553d973bc9c4ae5a07569a2cee18001386da6bd9ae57"; test "sync §A.1: round-1 reply creates two phantoms from the server igot cards" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply); // L3093-3094 — two new phantoms 4361252d…/f5116517…, keyed by interned rid. try std.testing.expectEqual(@as(usize, 2), client.phantoms.count()); try std.testing.expect(obs.phantom_created); const rid1 = try ms.store().intern(a1_phantom_1); const rid2 = try ms.store().intern(a1_phantom_2); try std.testing.expect(client.phantoms.contains(rid1)); try std.testing.expect(client.phantoms.contains(rid2)); } test "sync §A.1: server does not gimme the held ancestor; gimme records peer-lacks" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // L4099-4100 — the client announced igot for the two gimme'd hashes, so it // holds them; pre-store so the round-1 gimme cards queue them for upload. const rid1 = try ms.store().put(a1_gimme_1, "manifest payload\n"); const rid2 = try ms.store().put(a1_gimme_2, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply); // (b) — the two server gimme cards (L4108-4109) queue outbound content. try std.testing.expect(obs.content_queued); try std.testing.expectEqual(@as(usize, 2), client.send_queue.count()); try std.testing.expect(client.send_queue.contains(rid1)); try std.testing.expect(client.send_queue.contains(rid2)); // The exact peer-lacks set is the two gimme'd hashes; the ancestor // c5b2e0ec… is absent because the server already holds it (L4114) and so // never gimme's it back — igotDisposition ignores an already-held hash. var rt = RoundTables.init(std.testing.allocator); defer rt.deinit(); try rt.recordPeerLacks(a1_gimme_1); try rt.recordPeerLacks(a1_gimme_2); try std.testing.expect(rt.peerLacks(a1_gimme_1)); try std.testing.expect(rt.peerLacks(a1_gimme_2)); try std.testing.expect(!rt.peerLacks(a1_held_ancestor)); try std.testing.expectEqual(IgotDisposition.ignore, igotDisposition(.client, .sync, true)); } test "sync §A.1: both §3.7 conditions hold after round 1, exchange continues" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // Client holds the two gimme'd hashes (L4099-4100), so (b) fires. _ = try ms.store().put(a1_gimme_1, "manifest payload\n"); _ = try ms.store().put(a1_gimme_2, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply); try std.testing.expect(obs.phantom_table_nonempty and obs.phantom_created); // (a) try std.testing.expect(obs.content_queued); // (b) try std.testing.expect(continueExchange(obs)); try std.testing.expectEqual(ClientState.exchange, client.state); } test "sync §A.1: round-2 delivers two full-content files and empties the phantom set" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply); // Both file cards are full-content (two-argument) form (L3123-3124); the // stated payload sizes are 270 (L3142) and 31 (L3150). const rid1 = try ms.store().intern(a1_phantom_1); const rid2 = try ms.store().intern(a1_phantom_2); try std.testing.expectEqual(@as(usize, 270), ms.store().content(rid1).?.len); try std.testing.expectEqual(@as(usize, 31), ms.store().content(rid2).?.len); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(obs.versioned_content_delivered and !obs.phantom_created); } test "sync §A.1: no round 3 — the client terminates after round 2" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // Round 1 queues the two held gimmes; round 2's reply carries no gimme. _ = try ms.store().put(a1_gimme_1, "manifest payload\n"); _ = try ms.store().put(a1_gimme_2, "alpha content from client\n"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply); // L4179-4182 — round 2 delivered content but its reply had no gimme, so // nothing was appended this round: the per-round file-send count is zero. try std.testing.expect(!obs.content_queued); // The per-session buffer still holds the two rids queued in round 1 (§3.1 // L605-608), yet §3.7(b) reads the per-round count, not the buffer. try std.testing.expectEqual(@as(usize, 2), client.send_queue.count()); // §3.7 holds nowhere prospectively; the client stops. try std.testing.expect(!continueExchange(obs)); try std.testing.expectEqual(ClientState.terminated, client.state); } test "sync §A.2: clone reply discards the error, records seqno and push, advances" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(std.testing.allocator); try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0)); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a2_reply); // L3217 — clone_seqno 0 = exhausted RID range. try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next); // L3208, §8.3/§11.1 — the project code arrives in the push card. const push = parsePushPull(tokenizeCard("push 34302acad64fe2397bd907704dec457899e2f9a3 c85e3efddf993e86e8325d08672ea94bdba6c2c4")).?; try std.testing.expectEqualStrings("c85e3efddf993e86e8325d08672ea94bdba6c2c4", push.projectcode); // r14 — a processed first clone reply advances to EXCHANGE, not aborted // (L3221-3224: the round-0 error is discarded, not fatal). try std.testing.expectEqual(ClientState.exchange, client.state); } test "sync §A.2: cards after the discarded error are still dispatched (non-reset)" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), a2_reply); // The closing timestamp line (L3209) follows error/clone_seqno/push; a // stop at the error card would leave it unseen (L3212-3216, no reset). try std.testing.expect(client.last_timestamp != null); try std.testing.expectEqualStrings("2026-07-02T18:26:49", client.last_timestamp.?.datetime); try std.testing.expectEqual(@as(i64, 0), obs.clone_seqno_next); } test "sync §A.4: both server replies surface the closing timestamp comment" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round1_reply); // L3439 — round-1 reply closes with the timestamp subform. try std.testing.expectEqualStrings("2026-07-02T18:31:13", client.last_timestamp.?.datetime); try std.testing.expectEqual(@as(u64, 0), client.last_timestamp.?.errors); _ = try client.consumeReply(std.testing.allocator, ms.store(), a1_round2_reply); // L3443 — round-2 reply closes with the same subform, N = 0. try std.testing.expectEqualStrings("2026-07-02T18:31:13", client.last_timestamp.?.datetime); try std.testing.expectEqual(@as(u64, 0), client.last_timestamp.?.errors); } fn consumeReplyOom(gpa: std.mem.Allocator) !void { var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); // Round 1 fills the phantom set (two igot) and peer-lacks (two gimme); no // card in it is fatal or unimplemented, so OOM is the only non-success. _ = client.consumeReply(gpa, ms.store(), a1_round1_reply) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.FatalProtocolError, error.NotImplemented => unreachable, }; } test "sync §3.2: consumeReply covers OOM paths (phantom set + peer-lacks fill)" { try std.testing.checkAllAllocationFailures(std.testing.allocator, consumeReplyOom, .{}); } // §7.5 — an arbitrary 64-hex hash for the private-marker tests. No expected // byte derives from it; the store keys under the literal name and the asserted // value (private_content_delivered) comes from the r1/r4 rules alone. const p75_hash = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"; test "sync §7.5 r1: a private card marks the next file card as private" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // §7.5 L2381-2385 — with the grant present the bound content is stored, so // `private_content_delivered` is the observable proof the marker was bound. var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(std.testing.allocator); // L2359-2360 — "A private card … marks the next … file or cfile card as // carrying private content." const body = "private\nfile " ++ p75_hash ++ " 1\nx"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(obs.private_content_delivered); try std.testing.expect(!client.private_pending); // r4 — consumed by the file card } test "sync §7.5 r2: intervening comment/non-content cards do not consume the marker" { // L2361-2362 — "intervening comment lines and other non-content cards do // not consume it." const intervening = [_][]const u8{ "# a comment\n", "pragma foo bar\n" }; for (intervening) |line| { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = try std.mem.concat(std.testing.allocator, u8, &.{ "private\n", line }); defer std.testing.allocator.free(body); _ = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(client.private_pending); // still set — nothing consumed it } } test "sync §7.5 r3: a repeated private card leaves the marker set" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // L2362-2363 — "a repeated private card leaves the flag set." _ = try client.consumeReply(std.testing.allocator, ms.store(), "private\nprivate\n"); try std.testing.expect(client.private_pending); } test "sync §7.5 r4: the marker is consumed when the next cfile card handling begins" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // §7.5 L2381-2385 — grant present so the bound cfile is stored (delivered). var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(std.testing.allocator); // Build the §4.1 container in-test (uncompressed size 1 for the byte "y"). const container = try zlibContainerEncode(std.testing.allocator, "y", .default); defer std.testing.allocator.free(container); const header = try std.fmt.allocPrint(std.testing.allocator, "private\ncfile {s} 1 {d}\n", .{ p75_hash, container.len }); defer std.testing.allocator.free(header); const body = try std.mem.concat(std.testing.allocator, u8, &.{ header, container }); defer std.testing.allocator.free(body); // L2363-2364 — "the flag is consumed only when handling of the next file or // cfile card begins." const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(obs.private_content_delivered); try std.testing.expect(!client.private_pending); } test "sync §7.5 r5: the pending marker persists across reply bodies in a session" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // §7.5 L2381-2385 — grant present so body 2's file card delivers (stores). var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(std.testing.allocator); // L2364-2366 — "A client receiver carries the pending marker across // successive reply bodies within a single sync session." Body 1 keeps the // round alive so a 2nd reply is legal: the igot creates a phantom (§3.7(a) // → round_continues → state stays .exchange), and being a non-content card // it does not consume the marker (r2). const body1 = "private\nigot " ++ a1_phantom_1 ++ "\n"; _ = try client.consumeReply(std.testing.allocator, ms.store(), body1); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expect(client.private_pending); // carried into the next body // Body 2 delivers the announced artifact; that file card consumes the marker. const body2 = "file " ++ a1_phantom_1 ++ " 1\nx"; const obs2 = try client.consumeReply(std.testing.allocator, ms.store(), body2); try std.testing.expect(obs2.private_content_delivered); try std.testing.expect(!client.private_pending); } test "sync §7.5 r6: a pending marker does not carry across the session boundary" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); // L2366-2367 — "only a subsequent file or cfile card, or the end of that // session, clears it." A fresh Client (new session) starts clear; a marker // left pending in a prior session never binds a content card in a new one. var c1 = Client{ .operation = .sync, .state = .exchange }; _ = try c1.consumeReply(std.testing.allocator, ms.store(), "private\n"); try std.testing.expect(c1.private_pending); try std.testing.expectEqual(ClientState.terminated, c1.state); c1.deinit(std.testing.allocator); var c2 = Client{ .operation = .sync, .state = .exchange }; defer c2.deinit(std.testing.allocator); try std.testing.expect(!c2.private_pending); // new session starts clear const body = "file " ++ p75_hash ++ " 1\nx"; const obs = try c2.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(!obs.private_content_delivered); // no leak from session 1 } test "sync §7.5 r7: a reply body ending with the marker still pending is not fatal (rejection)" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // L2369-2371 — "Reaching the end of a client reply body with the marker // still pending is not by itself a fatal protocol error." The body returns // an observation (no error) and ends in the ordinary .terminated round end, // not .aborted. _ = try client.consumeReply(std.testing.allocator, ms.store(), "private\n# c\n"); try std.testing.expect(client.private_pending); try std.testing.expectEqual(ClientState.terminated, client.state); } test "sync §7.5 r8: a private card carrying an argument is a fatal card-arity abort (rejection)" { // r8 — "a private card carrying any argument is a §5.2 card-arity violation // and a fatal strict-accept abort (§3.9)." ABNF L4643 fixes arity 1; §5.2 // L1770-1776 (defined operator + bad arg count → its own arity rule) and // L1809-1813 (the client aborts the round, no outbound error card). const bad = [_][]const u8{ "private x\n", "private a b\n" }; for (bad) |body| { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(error.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expect(!client.private_pending); // never set on the arity-fail path } } const a1_phantom_1 = "4361252d0f450879dcd2e0b6a42b95ffe8a41d8e948130253ae8eefa7f4b79ea"; const a1_phantom_2 = "f5116517775075ea483901021282ba97eebe5a2b993a47eb78198adc10efe128"; test "sync §A.4: a client nonce comment is skipped with no store or obs effect" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // L3425, L3431-3433 — a `#`-prefixed 40-hex nonce is not the timestamp // subform, so it is skipped silently. const body = "# F2A895EF75B47B7157664A7FEAC978EFFFDB01C4\n"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(!obs.versioned_content_delivered and !obs.phantom_created); try std.testing.expect(client.last_timestamp == null); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); // store untouched } test "sync §3.3 r24: an igot for an already-held artifact creates no phantom" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); _ = try ms.store().put(a1_phantom_1, "held content"); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "igot " ++ a1_phantom_1 ++ "\n"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(!obs.phantom_created); } test "sync §3.3: a repeated igot of one hash counts a single phantom" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "igot " ++ a1_phantom_1 ++ "\nigot " ++ a1_phantom_1 ++ "\n"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(obs.phantom_created); try std.testing.expectEqual(@as(usize, 1), client.phantoms.count()); } test "sync §3.3 r27: a malformed gimme hash is fatal at parse, never asserted downstream" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "gimme deadbeef00\n"; // 10 hex — not a 40/64 hash token try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); } test "sync §3.7(a): content for a never-phantom'd hash does not underflow the set" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // File for an unseen hash: the remove is a no-op, no trap under ReleaseSafe. const body = "file " ++ a1_phantom_1 ++ " 3\nabc"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(obs.versioned_content_delivered); try std.testing.expect(!obs.phantom_table_nonempty); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); } test "sync §3.8 r19: a negative wire clone_seqno is fatal at parse, never stored" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(std.testing.allocator); const body = "clone_seqno -1\n"; try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); } test "sync §3.9: a clone card in a reply is a fatal protocol violation, not NotImplemented" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "clone 3 1\n"; try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); // store unchanged } test "sync §5: an out-of-scope legal reply card is a strict boundary, not a crash" { // 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 std.testing.expectError(Client.ConsumeError.NotImplemented, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); // store unchanged } } test "sync §3.7(a): an igot then a file for the same hash dephantomizes within the round" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); // A reply that announces then delivers one hash: the igot creates the // phantom, the file in the same body removes it. phantom_created stays set // (it happened) while the table ends empty — the two are independent, so // phantom_table_nonempty is the authority for §3.7(a), never phantom_created. const body = "igot " ++ a1_phantom_1 ++ "\nfile " ++ a1_phantom_1 ++ " 3\nabc"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(obs.phantom_created); try std.testing.expect(obs.versioned_content_delivered); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(!obs.phantom_table_nonempty); } // --------------------------------------------------------------------------- // §3.6 / §6.1 / §10.3 — reply-dispatch wiring: cookie record, login/reqconfig // strict-accept aborts, config framing consume. // --------------------------------------------------------------------------- test "sync §3.6: consumeReply stores a cookie into the held CookieStore" { // §3.6 L913-914 — "The client that receives such a card MUST store the // value"; TEXT is opaque with no escapes, so it stores verbatim. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), "cookie s3cr3t\n"); try std.testing.expectEqualStrings("s3cr3t", client.cookie.value().?); try std.testing.expect(obs.cookie_stored); } test "sync §3.6: a second cookie overwrites the first" { // §3.6 L914-915 — "replacing any previously held cookie for that server". var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), "cookie v1\ncookie v2\n"); try std.testing.expectEqualStrings("v2", client.cookie.value().?); } test "sync §3.6: multiple cookie cards in one reply leave the last, none rejected" { // §3.6 L920-921 — "must be prepared to receive more than one cookie card in // a single request": each is accepted, the last wins, none aborts. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), "cookie one\ncookie two\ncookie three\n"); try std.testing.expectEqualStrings("three", client.cookie.value().?); try std.testing.expect(client.state != .aborted); } test "sync §3.6/§4.3: cookie TEXT is defossilized before storing" { // §4.3 — cookie TEXT is fossilize-encoded like error/message TEXT; the wire // token `a\sb` decodes to `a b` (the inverse of writeCookieCard("a b")). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("cookie a\\sb", writeCookieCard("a b", &buf)); _ = try client.consumeReply(std.testing.allocator, ms.store(), "cookie a\\sb\n"); try std.testing.expectEqualStrings("a b", client.cookie.value().?); } test "sync §3.9: a cookie card never aborts, reports a non-fatal observation" { // §3.9 L1312 — "A cookie card from an unexpected server is tolerated": no // abort, a non-fatal round observation, the state machine stays live. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const obs = try client.consumeReply(std.testing.allocator, ms.store(), "cookie x\n"); try std.testing.expect(obs.cookie_stored); try std.testing.expect(client.state != .aborted); } test "sync §5.2/§B.4: a cookie card with no TEXT token is a fatal arity abort (rejection)" { // §B.4 L4622 `cookie-card = "cookie" SP fossilized LF` — exactly two tokens. // A bare `cookie\n` (arity 1) fails the card's argument check; §5.2 L1809-1813 // makes the client record a local warning and abort the round. §3.9's cookie // tolerance (L1312) is scoped to an unexpected *server*, not a missing TEXT. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "cookie\n")); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expect(client.cookie.value() == null); } test "sync §3.6/§5.1: a cookie card carries no byte payload; dispatch resumes at the next line" { // §5.1 — cookie has no byte-counted payload, so the cursor resumes at the // next line offset. The file card after it parses and delivers content, // proving the resume point was payload_start, not past any bytes. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "cookie tok\nfile " ++ a1_phantom_1 ++ " 3\nabc"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expectEqualStrings("tok", client.cookie.value().?); try std.testing.expect(obs.versioned_content_delivered); } test "sync §6.1/§3.9: a login card in a server reply is a fatal protocol violation (rejection)" { // §6.1 L1919-1920 — login is a request-only card ("carries exactly one // login card"); in a server reply it is a strict-accept abort (§3.9). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "login alice deadbeef cafef00d\n"; try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); } test "sync §3.8 r15/r18: login-in-reply aborts from both reply-bearing states" { // §3.8 Table 3 — the abort routes through Client.abort: `fatal_error` from // exchange, `clone_fatal` from clone_round0; both land in `aborted`. const cases = [_]struct { op: Operation, state: ClientState }{ .{ .op = .sync, .state = .exchange }, .{ .op = .clone, .state = .clone_round0 }, }; for (cases) |c| { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = c.op, .state = c.state }; defer client.deinit(std.testing.allocator); const body = "login alice deadbeef cafef00d\n"; try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); } } test "sync §10.3/§3.9: a reqconfig card in a server reply is a fatal abort (rejection)" { // §10.3 L3272 — "A client sends reqconfig NAME": reqconfig is client→server // only; the reverse flow is a config push, not reqconfig. A reqconfig in a // server reply is a strict-accept abort (§3.9). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "reqconfig /ui\n")); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); } test "sync §10.3/§5.1: consumeReply frames a config payload by its SIZE byte count" { // §10.3 L3278 — `config GROUPNAME SIZE` then SIZE content bytes. The payload // embeds an LF (`A\nBCD` = 5 bytes); byte-count framing (never scanning) // means the embedded LF is not a card boundary and the trailing cookie // still dispatches. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), "config /config 5\nA\nBCD\ncookie z\n"); try std.testing.expectEqualStrings("z", client.cookie.value().?); } test "sync §10.3: a config card's trailing newline is consumed" { // §10.3 L3320-3322 — "A single newline byte follows the content payload ... // receivers MUST consume this trailing newline". The cookie dispatches only // if the LF after `abc` was consumed. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = try client.consumeReply(std.testing.allocator, ms.store(), "config /config 3\nabc\ncookie z\n"); try std.testing.expectEqualStrings("z", client.cookie.value().?); } test "sync §10.3: dispatch resumes at start + SIZE + 1 after a config card" { // §10.3 — after SIZE payload bytes and the mandatory trailing LF, the next // card begins at start + SIZE + 1. A following file card delivers content // only if the resume offset is exact (off-by-one would misparse it). var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); const body = "config /config 3\nabc\nfile " ++ a1_phantom_1 ++ " 3\nXYZ"; const obs = try client.consumeReply(std.testing.allocator, ms.store(), body); try std.testing.expect(obs.versioned_content_delivered); } test "sync §10.3/§7: a config frame past the reply body is a fatal abort (rejection)" { // §10.3/§3.9 — SIZE 10 but only 4 bytes follow: ConfigFrameError.Truncated // routes through Client.abort to a fatal protocol error. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "config /config 10\nabc\n")); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); } test "sync §10.3: a config card missing the trailing newline is a fatal abort (rejection)" { // §10.3 L3320-3322 — the byte at start+SIZE is `X`, not the mandated LF: // ConfigFrameError.MissingNewline routes through Client.abort. var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), "config /config 3\nabcX\n")); try std.testing.expectEqual(ClientState.aborted, client.state); } test "sync §10.3: a malformed config header is a fatal abort (rejection)" { // §10.3 L3278 `config GROUPNAME SIZE` — each malformed header is a fatal // abort: (a) too few tokens, (b) non-numeric SIZE, (c) SIZE > u32, // (d) negative SIZE (§10.3 L3331: "SIZE is negative is a fatal protocol // error"). parseConfigCard yields Malformed / NegativeSize, both abort. const headers = [_][]const u8{ "config /config\n", // (a) no SIZE "config /config xy\n", // (b) non-numeric SIZE "config /config 4294967296\n", // (c) 2^32, past u32 "config /config -1\n", // (d) negative SIZE }; for (headers) |body| { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, client.consumeReply(std.testing.allocator, ms.store(), body)); try std.testing.expectEqual(ClientState.aborted, client.state); try std.testing.expectEqual(@as(usize, 0), ms.entries.items.len); } } test "sync §3.6/§4.3 fuzz: a well-formed cookie card round-trips through consumeReply" { // Invariant (§3.6 L913-914, §4.3): for any non-empty opaque value x, // consumeReply of `cookie <fossilize(x)>` stores exactly x and never // aborts. Fixed seed for exact replay. var prng = std.Random.DefaultPrng.init(0xC0_0C_1E_FA); const rand = prng.random(); var raw: [48]u8 = undefined; var token: [96]u8 = undefined; var card: [128]u8 = undefined; var round: usize = 0; while (round < 512) : (round += 1) { // §B.4 L4622/§5.2 — interleave the arity-reject shape: a `cookie` line // whose only trailing content is a whitespace run tokenizes to arity 1, // which the client MUST reject as fatal and store nothing, whatever the // run length. if (rand.boolean()) { const bp = "cookie"; @memcpy(card[0..bp.len], bp); const ws = rand.intRangeAtMost(usize, 0, 8); for (card[bp.len..][0..ws]) |*b| b.* = if (rand.boolean()) ' ' else '\t'; card[bp.len + ws] = '\n'; const bad = card[0 .. bp.len + ws + 1]; var ms2 = repostate.MemStore.init(std.testing.allocator); defer ms2.deinit(); var bad_client = Client{ .operation = .sync, .state = .exchange }; defer bad_client.deinit(std.testing.allocator); try std.testing.expectError(Client.ConsumeError.FatalProtocolError, bad_client.consumeReply(std.testing.allocator, ms2.store(), bad)); try std.testing.expectEqual(ClientState.aborted, bad_client.state); try std.testing.expect(bad_client.cookie.value() == null); continue; } const n = rand.intRangeAtMost(usize, 1, raw.len); // non-empty: empty has no TEXT token for (raw[0..n]) |*b| b.* = rand.int(u8); // any byte: LF/NUL/SP all legal in x const enc = artifact.encodeEscapes(raw[0..n], &token); // whitespace-free wire token const prefix = "cookie "; @memcpy(card[0..prefix.len], prefix); @memcpy(card[prefix.len..][0..enc.len], enc); card[prefix.len + enc.len] = '\n'; const body = card[0 .. prefix.len + enc.len + 1]; var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| { try std.testing.expect(e == error.OutOfMemory); continue; }; try std.testing.expect(client.state != .aborted); try std.testing.expectEqualStrings(raw[0..n], client.cookie.value().?); } } test "sync §10.3 fuzz: config byte-count framing survives an adversarial payload" { // Invariant (§10.3 L3278/L3320-3322): a config card frames its payload by // SIZE alone, so a trailing cookie always dispatches regardless of payload // bytes — embedded LF/NUL never fool the framer into an early boundary. var prng = std.Random.DefaultPrng.init(0xC0_F1_6F_1E); const rand = prng.random(); var payload: [64]u8 = undefined; var card: [160]u8 = undefined; var round: usize = 0; while (round < 512) : (round += 1) { const size = rand.uintLessThan(usize, payload.len + 1); for (payload[0..size]) |*b| b.* = rand.int(u8); // may embed LF/NUL const header = std.fmt.bufPrint(&card, "config /config {d}\n", .{size}) catch unreachable; var w = header.len; @memcpy(card[w..][0..size], payload[0..size]); w += size; card[w] = '\n'; // mandatory trailing LF w += 1; const suffix = "cookie mark\n"; @memcpy(card[w..][0..suffix.len], suffix); w += suffix.len; const body = card[0..w]; var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(std.testing.allocator); _ = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| { try std.testing.expect(e == error.OutOfMemory); continue; }; try std.testing.expect(client.state != .aborted); try std.testing.expectEqualStrings("mark", client.cookie.value().?); } } // --------------------------------------------------------------------------- // §7.2 — cfile card (full-content) client reply-dispatch through consumeReply. // --------------------------------------------------------------------------- /// Build `cfile HASH USIZE CSIZE\n` followed by `payload` bytes. USIZE and /// CSIZE are caller-supplied so a rejection test can frame a deliberate lie /// (a CSIZE past the body, a USIZE that disagrees with the decoded length). /// Owned by the caller. fn buildCfileBody(gpa: std.mem.Allocator, hash: []const u8, usize_field: usize, csize_field: usize, payload: []const u8) ![]u8 { const header = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ hash, usize_field, csize_field }); defer gpa.free(header); const body = try gpa.alloc(u8, header.len + payload.len); @memcpy(body[0..header.len], header); @memcpy(body[header.len..], payload); return body; } test "sync §7.2-r6: consumeReply stores a full-content cfile's decompressed bytes under HASH" { const gpa = std.testing.allocator; const plaintext = "cfile artifact payload\n"; const enc = try zlibContainerEncode(gpa, plaintext, .default); defer gpa.free(enc); // §7.2-r1/r5 — USIZE is the reconstructed length (syntax only); CSIZE is // the wire byte count that actually frames the payload. const body = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(obs.versioned_content_delivered); // §3.7(a) versioned progress const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); // §7.2-r6 decompressed bytes under HASH } test "sync §7.2-r7: an igot then a full-content cfile for the same hash dephantomizes" { const gpa = std.testing.allocator; const plaintext = "dephantomized by cfile"; const enc = try zlibContainerEncode(gpa, plaintext, .default); defer gpa.free(enc); const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc); defer gpa.free(cfile); // §3.7(a)/§3.3 r36 — the igot creates the phantom, the cfile in the same // body removes it exactly as a full-content file card would (r7). const body = try std.fmt.allocPrint(gpa, "igot {s}\n{s}", .{ test_hash_a, cfile }); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(obs.phantom_created); try std.testing.expect(obs.versioned_content_delivered); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(!obs.phantom_table_nonempty); } test "sync §7.2-r9: a full-content cfile payload is container-decoded exactly once" { const gpa = std.testing.allocator; // §7.2-r9 — the x-fossil-uncompressed outer body is already decoded, so the // cfile payload is container-decoded once. This plaintext is itself NOT a // §4.1 container: a spurious second decode pass would abort, so a clean // store of these exact bytes proves the single-decode contract. const plaintext = "plain artifact bytes, not a nested container"; if (zlibContainerDecode(gpa, plaintext, std.math.maxInt(usize))) |leaked| { gpa.free(leaked); try std.testing.expect(false); // guard: the plaintext must not itself be a valid container } else |_| {} const enc = try zlibContainerEncode(gpa, plaintext, .default); defer gpa.free(enc); const body = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(obs.versioned_content_delivered); try std.testing.expect(client.state != .aborted); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); } test "sync §5.1: consumeReply consumes the cfile trailing-LF padding when the payload does not end in LF" { const gpa = std.testing.allocator; // §7.5 L2383-2385 — 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.4 cfile-transfer — igot announces, cfile delivers, // clone_seqno closes the round. End-to-end: content stored, phantom gone. const plaintext = "version 3 clone artifact"; const enc = try zlibContainerEncode(gpa, plaintext, .default); defer gpa.free(enc); const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc); defer gpa.free(cfile); const body = try std.fmt.allocPrint(gpa, "igot {s}\n{s}clone_seqno 0\n", .{ test_hash_a, cfile }); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(obs.versioned_content_delivered); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(client.state != .aborted); } test "sync §8.2 L1406-1413: 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 L1384-1387). 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 L1406-1409 — 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 L1413 — 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 L1387/L1417: 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 L1417 — 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 L1159-1162 — USIZE "is parsed syntactically but not transfer-layer // validated"; the payload "is the repository's stored compressed full // artifact... retransmitted verbatim". §7.7 L1357-1363 — 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.1 L605-608) and // then does `content(rid).?` (§3.2 L358-360) — 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.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` (the basis's own // hash stands in as a valid-shape-but-wrong name). const wrong_hash = basis_hash; 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 file")); // §5.2 framing try std.testing.expectEqual(held_before, ss.store().count()); // §7.7 — no new artifact interned/stored } test "sync §7.4/§3.8: server respond rejects a delta-form pushed file whose basis it does not hold" { // §3.8 — the stateless server keeps no cross-request PendingDeltaQueue, so it // cannot retain a dangling delta until a later request delivers the basis // (the client's §7.4 dangling path). A basis absent from the store is rejected // cleanly (never silently dropped); this branch is unreachable by a conformant // fossil push, which deltas only against advertised (igot) holdings. 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 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.startsWith(u8, reply, "error malformed file")); // clean rejection try std.testing.expect(ss.store().lookup(target_hash) == null); // target never landed try std.testing.expectEqual(held_before, ss.store().count()); // store untouched } 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 "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/NotImplemented/OOM) or a // clean parse — never a crash. const plen = rand.intRangeAtMost(usize, 0, raw.len); for (raw[0..plen]) |*b| b.* = rand.int(u8); const csize = rand.intRangeAtMost(usize, 0, raw.len + 8); // may over/under-run the payload const body = try buildCfileBody(gpa, test_hash_a, rand.int(u16), csize, raw[0..plen]); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); _ = client.consumeReply(gpa, ms.store(), body) catch |e| switch (e) { error.OutOfMemory, error.FatalProtocolError, error.NotImplemented => continue, }; } } } // --------------------------------------------------------------------------- // §7.5 — Private-content receive-side gates through consumeReply. // --------------------------------------------------------------------------- test "sync §7.5 L2383-2385: a private file without the grant is discarded without error" { const gpa = std.testing.allocator; // `private` then a full-content file card, with no send-private grant. const body = "private\nfile " ++ test_hash_a ++ " 5\nHELLO"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(client.state != .aborted); // discarded without error try std.testing.expect(ms.store().lookup(test_hash_a) == null); // store untouched try std.testing.expect(obs.private_content_discarded); try std.testing.expect(!obs.versioned_content_delivered); // no §3.7(a) progress try std.testing.expect(!obs.private_content_delivered); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); // no phantom created // consumeReply asserts cursor == body.len, so the offset resumed past the // 5 payload bytes: HELLO was never parsed as a card. } test "sync §7.5 L2383-2385: a private cfile without the grant is discarded without decoding" { const gpa = std.testing.allocator; // A CSIZE-framed payload that is NOT a valid §4.1 container: if the store // path ran, zlibContainerDecode would abort — so a non-aborting round // proves the discard gate fires before any decode. const garbage = "not a fossil zlib container at all"; const cfile = try buildCfileBody(gpa, test_hash_a, 99, garbage.len, garbage); defer gpa.free(cfile); const body = try std.fmt.allocPrint(gpa, "private\n{s}", .{cfile}); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(client.state != .aborted); // no decode required to advance try std.testing.expect(ms.store().lookup(test_hash_a) == null); // store untouched try std.testing.expect(obs.private_content_discarded); try std.testing.expect(!obs.versioned_content_delivered); } test "sync §7.5 L2381-2385: a private file with the grant is stored and dephantomized" { const gpa = std.testing.allocator; // A public igot seeds the phantom; `private` then the file card, with the // grant present, stores the content and removes the phantom. const body = "igot " ++ test_hash_a ++ "\nprivate\nfile " ++ test_hash_a ++ " 5\nHELLO"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings("HELLO", ms.store().content(rid).?); // store.put occurred try std.testing.expect(obs.private_content_delivered); try std.testing.expect(!obs.private_content_discarded); try std.testing.expect(obs.versioned_content_delivered); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); // phantom for HASH removed } test "sync §7.5 L2381-2385: the store gate is general — a private cfile with the grant is decoded and stored" { const gpa = std.testing.allocator; const plaintext = "private cfile stored"; const enc = try zlibContainerEncode(gpa, plaintext, .default); defer gpa.free(enc); const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, enc.len, enc); defer gpa.free(cfile); const body = try std.fmt.allocPrint(gpa, "private\n{s}", .{cfile}); defer gpa.free(body); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); // decoded + stored try std.testing.expect(obs.private_content_delivered); try std.testing.expect(obs.versioned_content_delivered); } test "sync §7.5 receive: a private marker survives uvfile and binds a later file" { const gpa = std.testing.allocator; const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n"), ); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expect(client.private_pending); const obs = try client.consumeReply(gpa, ms.store(), "file " ++ test_hash_a ++ " 5\nHELLO"); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings("HELLO", ms.store().content(rid).?); try std.testing.expect(obs.private_content_delivered); try std.testing.expect(!obs.private_content_discarded); try std.testing.expect(!client.private_pending); } test "sync §7.5 receive: a private marker survives uvfile and binds a later cfile" { const gpa = std.testing.allocator; const empty_hash = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; const plaintext = "private cfile after uvfile"; const encoded = try zlibContainerEncode(gpa, plaintext, .default); defer gpa.free(encoded); const cfile = try buildCfileBody(gpa, test_hash_a, plaintext.len, encoded.len, encoded); defer gpa.free(cfile); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(gpa); try std.testing.expectError( Client.ConsumeError.NotImplemented, client.consumeReply(gpa, ms.store(), "private\nuvfile a\\sb 1 " ++ empty_hash ++ " 0 0\n"), ); try std.testing.expectEqual(ClientState.exchange, client.state); try std.testing.expect(client.private_pending); const obs = try client.consumeReply(gpa, ms.store(), cfile); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expectEqualStrings(plaintext, ms.store().content(rid).?); try std.testing.expect(obs.private_content_delivered); try std.testing.expect(!obs.private_content_discarded); try std.testing.expect(!client.private_pending); } test "sync §7.5 receive: an end-of-reply private marker is nonfatal and remains pending" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), "private\n"); try std.testing.expectEqual(ClientState.terminated, client.state); try std.testing.expect(client.private_pending); } test "sync §7.5 L2476-2478: a private igot is not an error and processing continues" { const gpa = std.testing.allocator; // A private igot followed by a second card in the same body: no abort, and // the trailing cookie card is dispatched (its store effect is observable). const body = "igot " ++ test_hash_a ++ " 1\ncookie tok\n"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(client.state != .aborted); try std.testing.expect(obs.cookie_stored); // the following card was processed } test "sync §7.5 L2476-2478: an unauthorized private igot for an unknown hash creates no phantom" { const gpa = std.testing.allocator; // §7.5 L2476-2478 scopes the no-phantom disposition to a private igot from // a peer that LACKS private-content authorization: at the client receiver // that is private_granted == false (no accepted send-private pragma). Such // an igot is not an error and does not intern or phantom HASH (lookup, not // intern) — the content will never be sent to this receiver. const body = "igot " ++ test_hash_a ++ " 1\n"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = false }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expectEqual(@as(usize, 0), client.phantoms.count()); try std.testing.expect(!obs.phantom_created); try std.testing.expect(ms.store().lookup(test_hash_a) == null); // lookup, not intern } test "sync §7.5 L2481-2483: an authorized private igot for an unknown hash creates a phantom to pull it" { const gpa = std.testing.allocator; // §7.5 L2481-2483 — when the receiver holds private-content authorization // (private_granted, the send-private pragma accepted and gated on the // client holding Private) it MAY pull private content, so a private igot is // handled like a public one (§3.3 r24): it interns HASH and creates a // phantom so §3.3(c) requests the content, rather than suppressing it. const body = "igot " ++ test_hash_a ++ " 1\n"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange, .private_granted = true }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(ms.store().lookup(test_hash_a) != null); // interned, unlike the unauthorized case const rid = ms.store().lookup(test_hash_a).?; try std.testing.expect(client.phantoms.getPtr(rid) != null); try std.testing.expect(obs.phantom_created); try std.testing.expectEqual(@as(usize, 1), client.phantoms.count()); } test "sync §7.5 L2478-2480: a private igot marks an existing phantom private" { const gpa = std.testing.allocator; // A public igot creates the phantom; the private igot for the same hash // marks it private without adding a second entry. const body = "igot " ++ test_hash_a ++ "\nigot " ++ test_hash_a ++ " 1\n"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); const rid = ms.store().lookup(test_hash_a).?; try std.testing.expect(client.phantoms.getPtr(rid).?.private); try std.testing.expect(obs.phantom_marked_private); try std.testing.expectEqual(@as(usize, 1), client.phantoms.count()); } test "sync §7.5 L2479-2480 / §3.3 L845-846: a marked-private phantom is gimme-ineligible" { const gpa = std.testing.allocator; const body = "igot " ++ test_hash_a ++ "\nigot " ++ test_hash_a ++ " 1\n"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), body); const rid = ms.store().lookup(test_hash_a).?; const marked = client.phantoms.getPtr(rid).?.private; try std.testing.expect(marked); // §3.3(c): with private-content sync not enabled, the marked-private phantom // is not gimme-eligible — the receiver stops re-requesting it (the single- // round half of deferred DST invariant (a)). try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = marked, .private_sync_enabled = false })); } test "sync §7.5 L2480-2481: a private igot records peer-has and processing continues" { const gpa = std.testing.allocator; const body = "igot " ++ test_hash_a ++ " 1\ncookie tok\n"; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .sync, .state = .exchange }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(obs.private_igot_peer_has); try std.testing.expect(obs.cookie_stored); // a trailing card is still processed } test "sync §7.5 L2482-2483: send-private is emitted only when the client holds Private" { // §7.5 L2482-2483 — pulling private content MUST be preceded by a send- // private the client is entitled to send; §10.5 L3395-3397 gates that // entitlement on the client holding the Private (x) capability. const Case = struct { wants: bool, has_x: bool, want: bool }; const cases = [_]Case{ .{ .wants = true, .has_x = true, .want = true }, .{ .wants = true, .has_x = false, .want = false }, .{ .wants = false, .has_x = true, .want = false }, .{ .wants = false, .has_x = false, .want = false }, }; for (cases) |c| try std.testing.expectEqual(c.want, clientSendsSendPrivate(c.wants, c.has_x)); // The caller derives has_private_x from the established capability set (B1). const held = capabilitySetFromLetters("x"); try std.testing.expect(clientSendsSendPrivate(true, held.contains(.private))); const unheld = capabilitySetFromLetters("oi"); try std.testing.expect(!clientSendsSendPrivate(true, unheld.contains(.private))); } // --------------------------------------------------------------------------- // 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 L4090-4114 — 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 L4096 / §10.1 — a dated client/server version pragma pair. const h2_server_version = VersionInfo{ .version = 22800, .datetime = .{ .date = 20260311, .time = 113146 } }; // §A.1 L4096-4102 — 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 L4107-4110 — 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 L4108-4109 / §3.2 L735-736 — 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 L4110-4111 / §3.2 L722-723 — 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 L4113-4114 — 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.8 L1089-1091 — 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); // §10.1 L2319-2323 (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); // §10.1 L2320-2321 — 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 L1963-1969 — 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 L1988-1993 — 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 L1990-1993 / §5.2 L1800-1808 — 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 L1927-1931 — 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 L1932-1933 — 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 L1759-1762 — 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 command: bogus\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 L1800-1804) 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 file line\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 L4090-4093 — 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 L925-937 — 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 L925-937 — 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; } }; 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 }; // §8.2 L1384-1385 — 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 L1386-1387 — 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 L1386: 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 22800 ")); // Undated client-version suppresses it (§10.1 r12): the reply opens on cfile. 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.startsWith(u8, undated, "cfile ")); } test "sync §8.2 L1380-1382: 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 }; // Two cfiles for the held rids 1 and 3 — the phantom rid 2 is silently // skipped (§8.2 L1381 "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 L1369-1373 — 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 L690-692 — 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()); }