Artifact e198ecaa9c115597d9b6aa8ffb01a93dee9cb1842822d1b97c7802777d7455a0:
- File
src/sync_test.zig
— part of check-in
[620ed6a9c3]
at
2026-07-30 13:15:42
on branch shun-implementation
— shun S6: receive-side admission, asymmetric as the spec has it
SS7.7 L1434-1452, as one combined predicate: a target is shunned for admission when its complete name is in the shun table OR shun-sha1 applies to its SHA1 name. TargetAdmission.admits carries the whole rule, including the two card rules -- a clone file card BYPASSES the check and is stored; everything else consults it.
That asymmetry is deliberate and is the applied text. zit's own erratum argued the discard should apply in clone too and four reviews rejected it against fossil's xfer.c. A test pins the bypass so a later reader does not 'fix' it back into the shape that was refused.
For cfile the SS7.5 private discard runs first and the raw artifact-card count still increments -- both satisfied by placing the check below code that already existed, not by adding any.
Admission is target-only. A shunned DELTASRC is not independently rejected: a held basis still serves, and an unavailable one falls to ordinary dangling-delta handling whose phantom SS3.3 already excludes from gimme.
The planned e2e did not pin anything and was rewritten. A real zit push passes with the whole check removed, because transfer is receiver-driven and SS3.3 already stops this server begging for a shunned name. The shipped test POSTs an UNSOLICITED push+file container at a live serve -- the only shape that reaches this code -- and it goes red without it.
Smoke S10 is real interop coverage but cannot pin this slice for the same reason, and its header says so rather than letting a green be misread. (user: dmestas, size: 448255) [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, .unrestricted); try std.testing.expectEqual(@as(usize, 0), queue.itemCount()); for (0..n) |k| { var tgt_buf: [80]u8 = undefined; const tgt = try std.fmt.bufPrint(&tgt_buf, "target artifact number {d} reconstructed from the shared base", .{k}); const rid = try store.intern(&hashes[k]); try std.testing.expectEqualStrings(tgt, store.content(rid).?); } } test "sync §7.2/§7.4: a clone client retains a received full and delta cfile verbatim; wire() re-serves the exact containers" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const base = "the base artifact bytes with a long shared run so the forward delta is compact and its raw size differs from the reconstructed target"; const target = base ++ " with a small appended mutation"; const base_container = try zlibContainerEncode(gpa, base, .default); defer gpa.free(base_container); const raw = try delta.create(gpa, base, target); defer gpa.free(raw); const delta_container = try zlibContainerEncode(gpa, raw, .default); defer gpa.free(delta_container); const base_hash = test_hash_a; const target_hash = "c" ** 40; // the delta cfile's DELTASRC is base_hash // A clone reply in §8.2 RID order: the full base cfile precedes the delta // cfile, so the client reconstructs the delta immediately against the held // basis and retains the received delta container verbatim. var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n"); const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ base_hash, base.len, base_container.len }); defer gpa.free(bh); try body.appendSlice(gpa, bh); try body.appendSlice(gpa, base_container); try body.append(gpa, '\n'); // §5.1 optional trailing-LF padding const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ target_hash, base_hash, target.len, delta_container.len }); defer gpa.free(dh); try body.appendSlice(gpa, dh); try body.appendSlice(gpa, delta_container); try body.append(gpa, '\n'); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, s, body.items); // §7.2 — the base retained its received full container verbatim. const base_rid = s.lookup(base_hash).?; const bwf = s.wire(base_rid).?; try std.testing.expect(bwf.delta == null); try std.testing.expectEqualSlices(u8, base_container, bwf.container); // §7.2 — the delta retained its received container verbatim, named its basis, // and carries the reconstructed-target USIZE (delta §3.2), not the raw size. const target_rid = s.lookup(target_hash).?; const twf = s.wire(target_rid).?; try std.testing.expect(twf.delta != null); try std.testing.expectEqualSlices(u8, delta_container, twf.container); try std.testing.expectEqualStrings(base_hash, twf.delta.?.src_hash); try std.testing.expectEqual(@as(u64, target.len), twf.delta.?.target_size); // §7.4 — content() still reconstructs the original target (delta ∘ base). try std.testing.expectEqualStrings(target, s.content(target_rid).?); } test "sync §7.2/§7.4: a dangling reverse-delta cfile retains its §4.1 container through the queue; completion re-serves it verbatim" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); // A fossil reverse delta: the OLDER revision is stored as a delta of the // NEWER one, so the delta reconstructs OLDER from NEWER. const newer = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload"; const older = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload, cut"; const newer_container = try zlibContainerEncode(gpa, newer, .default); defer gpa.free(newer_container); const raw = try delta.create(gpa, newer, older); // OLDER = delta ∘ NEWER defer gpa.free(raw); const delta_container = try zlibContainerEncode(gpa, raw, .default); defer gpa.free(delta_container); const newer_hash = test_hash_a; const older_hash = "c" ** 40; // §8.2 clone order is ascending age: the OLDER revision's reverse-delta cfile // arrives BEFORE its NEWER basis, so it is dangling and retained. Pre-slice-A // the queue dropped the container and the artifact completed as full form; // now the container survives to a verbatim §7.2 re-serve. var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n"); const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ older_hash, newer_hash, older.len, delta_container.len }); defer gpa.free(dh); try body.appendSlice(gpa, dh); try body.appendSlice(gpa, delta_container); try body.append(gpa, '\n'); const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ newer_hash, newer.len, newer_container.len }); defer gpa.free(bh); try body.appendSlice(gpa, bh); try body.appendSlice(gpa, newer_container); try body.append(gpa, '\n'); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, s, body.items); // The dangling delta completed: the OLDER revision retained its received // container VERBATIM (not a full re-compression), named its basis, carries // the reconstructed-target USIZE, and reconstructs the original bytes. const older_rid = s.lookup(older_hash).?; const wf = s.wire(older_rid).?; try std.testing.expect(wf.delta != null); try std.testing.expectEqualSlices(u8, delta_container, wf.container); try std.testing.expectEqualStrings(newer_hash, wf.delta.?.src_hash); try std.testing.expectEqual(@as(u64, older.len), wf.delta.?.target_size); try std.testing.expectEqualStrings(older, s.content(older_rid).?); } test "sync §7.2: a clone stores received full and delta cfiles wire-backed, holding no memoised plaintext" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const base = "the base artifact bytes with a long shared run so the forward delta stays a compact copy payload and differs from the reconstructed target"; const target = base ++ " with a small appended mutation"; const base_container = try zlibContainerEncode(gpa, base, .default); defer gpa.free(base_container); const raw = try delta.create(gpa, base, target); defer gpa.free(raw); const delta_container = try zlibContainerEncode(gpa, raw, .default); defer gpa.free(delta_container); const base_hash = test_hash_a; const target_hash = "c" ** 40; // the delta cfile's DELTASRC is base_hash // §8.2 RID order: the full base cfile precedes the delta cfile, so the delta's // basis is held on arrival (the clone wire-backed fast path). var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n"); const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ base_hash, base.len, base_container.len }); defer gpa.free(bh); try body.appendSlice(gpa, bh); try body.appendSlice(gpa, base_container); try body.append(gpa, '\n'); const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ target_hash, base_hash, target.len, delta_container.len }); defer gpa.free(dh); try body.appendSlice(gpa, dh); try body.appendSlice(gpa, delta_container); try body.append(gpa, '\n'); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, s, body.items); // Peak stays O(wire): the clone-receive path retained only the §4.1 // containers; NO artifact's inflated plaintext is memoised in the store — // the regression this whole slice removes (a 257 MB repo once held ~14 GB). try std.testing.expectEqual(@as(usize, 0), ms.plaintextBytesHeld()); // §7.4 — content() still reconstructs each artifact on demand (memoising it // only then), so the plaintext is available lazily, not held eagerly. try std.testing.expectEqualStrings(base, s.content(s.lookup(base_hash).?).?); try std.testing.expectEqualStrings(target, s.content(s.lookup(target_hash).?).?); try std.testing.expect(ms.plaintextBytesHeld() > 0); // materialised by the reads above } test "sync §7.4: a completed dangling reverse-delta is stored wire-backed, holding no memoised plaintext" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); // A fossil reverse delta: the OLDER revision is stored as a delta of the NEWER // one, and (§8.2 ascending age) arrives BEFORE its basis — dangling, retained // in the pending queue, then completed when the NEWER full cfile lands. const newer = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload"; const older = "the NEWER artifact bytes with a long shared run so the reverse delta reconstructing the older revision stays a compact copy payload, cut"; const newer_container = try zlibContainerEncode(gpa, newer, .default); defer gpa.free(newer_container); const raw = try delta.create(gpa, newer, older); // OLDER = delta ∘ NEWER defer gpa.free(raw); const delta_container = try zlibContainerEncode(gpa, raw, .default); defer gpa.free(delta_container); const newer_hash = test_hash_a; const older_hash = "c" ** 40; var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n"); const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ older_hash, newer_hash, older.len, delta_container.len }); defer gpa.free(dh); try body.appendSlice(gpa, dh); try body.appendSlice(gpa, delta_container); try body.append(gpa, '\n'); const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ newer_hash, newer.len, newer_container.len }); defer gpa.free(bh); try body.appendSlice(gpa, bh); try body.appendSlice(gpa, newer_container); try body.append(gpa, '\n'); var client = Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, s, body.items); // The dangling delta completed through the queue and was stored WIRE-BACKED — // no reconstructed plaintext was memoised for it or its basis (peak O(wire)). try std.testing.expectEqual(@as(usize, 0), ms.plaintextBytesHeld()); try std.testing.expectEqualStrings(older, s.content(s.lookup(older_hash).?).?); try std.testing.expectEqualStrings(newer, s.content(s.lookup(newer_hash).?).?); } test "sync §7.4-r3: storing the deferred source completes the reconstruction" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const source = "the source artifact's stored bytes, pre-delta"; const target = "the source artifact's stored bytes, post-delta, longer"; const delta_bytes = try delta.create(std.testing.allocator, source, target); defer std.testing.allocator.free(delta_bytes); const card = try parseFileCard(tokenizeCard("file " ++ test_hash_a ++ " " ++ test_hash_b ++ " 1")); _ = try store.intern(card.deltasrc.?); // §7.4-r2 phantom before the source arrives var queue = PendingDeltaQueue{}; defer queue.deinit(std.testing.allocator); try queue.retain(std.testing.allocator, card.hash, card.deltasrc.?, .{ .raw = delta_bytes }); // The source artifact is subsequently stored (§7.4-r3's trigger). const source_rid = try store.put(card.deltasrc.?, source); try queue.completeFor(std.testing.allocator, store, card.deltasrc.?, .unrestricted); try std.testing.expectEqual(@as(usize, 0), queue.itemCount()); try std.testing.expectEqualStrings(source, store.content(source_rid).?); const hash_rid = try store.intern(card.hash); try std.testing.expectEqualStrings(target, store.content(hash_rid).?); } test "sync §7.4-r3: completion cascades through a delta chain to fixpoint (C<-A<-B)" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const test_hash_c = "c" ** 40; const b_bytes = "artifact B: the eventual on-wire source, base of the chain"; const a_bytes = "artifact A: reconstructed from B and itself the basis for C, longer"; const c_bytes = "artifact C: reconstructed from A, the tail of the chain, longer still"; const d_a = try delta.create(std.testing.allocator, b_bytes, a_bytes); // A from B defer std.testing.allocator.free(d_a); const d_c = try delta.create(std.testing.allocator, a_bytes, c_bytes); // C from A defer std.testing.allocator.free(d_c); // C and A both arrive before B: C names A as basis, A names B as basis. _ = try store.intern(test_hash_a); // §7.4-r2 phantom for A (C's source) _ = try store.intern(test_hash_b); // §7.4-r2 phantom for B (A's source) var queue = PendingDeltaQueue{}; defer queue.deinit(std.testing.allocator); try queue.retain(std.testing.allocator, test_hash_c, test_hash_a, .{ .raw = d_c }); try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = d_a }); // B is stored; completing for B must cascade A -> C to fixpoint, else C is a // permanent phantom (§7.4-r3) and §3.7 convergence breaks. _ = try store.put(test_hash_b, b_bytes); try queue.completeFor(std.testing.allocator, store, test_hash_b, .unrestricted); try std.testing.expectEqual(@as(usize, 0), queue.itemCount()); try std.testing.expectEqualStrings(a_bytes, store.content(try store.intern(test_hash_a)).?); try std.testing.expectEqualStrings(c_bytes, store.content(try store.intern(test_hash_c)).?); } test "sync §7.4-r3: completeFor only completes entries whose deltasrc matches" { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const source_b = "source b bytes"; const target_a = "target a bytes, reconstructed from source b"; const delta_ab = try delta.create(std.testing.allocator, source_b, target_a); defer std.testing.allocator.free(delta_ab); const other_source = "c" ** 40; const delta_ac = "opaque delta against a different, still-unheld source"; var queue = PendingDeltaQueue{}; defer queue.deinit(std.testing.allocator); try queue.retain(std.testing.allocator, test_hash_a, test_hash_b, .{ .raw = delta_ab }); try queue.retain(std.testing.allocator, test_hash_a, other_source, .{ .raw = delta_ac }); _ = try store.put(test_hash_b, source_b); try queue.completeFor(std.testing.allocator, store, test_hash_b, .unrestricted); try std.testing.expectEqual(@as(usize, 1), queue.itemCount()); 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, .unrestricted); } test "sync §7.4-r3: completeFor propagates allocation failure without leaking" { try std.testing.checkAllAllocationFailures(std.testing.allocator, completeForNoLeak, .{}); } fn manyDeltaRetainCompleteNoLeak(allocator: std.mem.Allocator) !void { var ms = repostate.MemStore.init(std.testing.allocator); defer ms.deinit(); const store = ms.store(); const base = "the shared basis these many deltas all reconstruct from"; _ = try store.intern(test_hash_b); const m = 24; // enough to force list growth (past its initial inline capacity) under injected OOM var deltas: [m][]const u8 = undefined; var hashes: [m][40]u8 = undefined; var made: usize = 0; defer for (deltas[0..made]) |d| std.testing.allocator.free(d); var queue = PendingDeltaQueue{}; defer queue.deinit(allocator); for (0..m) |k| { var tgt_buf: [64]u8 = undefined; const tgt = try std.fmt.bufPrint(&tgt_buf, "target {d} for the OOM sweep, longer than base", .{k}); const d = try delta.create(std.testing.allocator, base, tgt); deltas[k] = d; made += 1; _ = try std.fmt.bufPrint(&hashes[k], "{x:0>40}", .{k}); try queue.retain(allocator, &hashes[k], test_hash_b, .{ .raw = d }); } _ = try store.put(test_hash_b, base); try queue.completeFor(allocator, store, test_hash_b, .unrestricted); } test "sync §7.4-r1/r3: growable retain + cascade completeFor propagate allocation failure without leaking" { try std.testing.checkAllAllocationFailures(std.testing.allocator, manyDeltaRetainCompleteNoLeak, .{}); } test "sync §3.3 r27: gimme parses HASH; wrong arity or hash length is malformed" { try std.testing.expectEqualStrings(test_hash_a, (try parseGimmeCard(tokenizeCard("gimme " ++ test_hash_a))).hash); const sha3 = "c" ** 64; try std.testing.expectEqualStrings(sha3, (try parseGimmeCard(tokenizeCard("gimme " ++ sha3))).hash); try std.testing.expectError(CardParseError.Malformed, parseGimmeCard(tokenizeCard("gimme"))); try std.testing.expectError(CardParseError.Malformed, parseGimmeCard(tokenizeCard("gimme deadbeef00"))); try std.testing.expectError(CardParseError.Malformed, parseGimmeCard(tokenizeCard("gimme " ++ test_hash_a ++ " x"))); } test "sync §7.5 r3: private content transfers only with send-private accepted AND the receiving user holds Private" { // Send half — §3.3 gimmeEligible (:2697): a private phantom is // gimme-eligible for the round only once private sync is enabled. try std.testing.expect(!gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = false })); try std.testing.expect(gimmeEligible(.{ .peer_lacks = false, .shunned = false, .private = true, .private_sync_enabled = true })); // Receive half — the receiving user must separately hold Private. try std.testing.expectEqual(PrivateReceipt.discard, privateReceipt(false)); try std.testing.expectEqual(PrivateReceipt.store, privateReceipt(true)); } test "sync §7.5 r4: a receiver stores a private artifact only when the user holds Private" { try std.testing.expectEqual(PrivateReceipt.store, privateReceipt(true)); } test "sync §7.5 r5: a private artifact without the capability is discarded without error (rejection)" { try std.testing.expectEqual(PrivateReceipt.discard, privateReceipt(false)); } test "sync §7.5 r7: a delta on a private basis to an unauthorized peer forces full content" { const Case = struct { basis_private: bool, peer_has_private: bool, want: DeltaBasisSend }; const cases = [_]Case{ .{ .basis_private = true, .peer_has_private = false, .want = .force_full }, .{ .basis_private = true, .peer_has_private = true, .want = .delta_ok }, .{ .basis_private = false, .peer_has_private = false, .want = .delta_ok }, .{ .basis_private = false, .peer_has_private = true, .want = .delta_ok }, }; for (cases) |c| try std.testing.expectEqual(c.want, deltaBasisSend(c.basis_private, c.peer_has_private)); } test "sync §7.5 r8: a private file/cfile pushed without the Private capability is rejected as not-authorized (rejection)" { try std.testing.expectEqual(SendPrivateDecision.reject, serverSendPrivateDecision(false)); try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true)); try std.testing.expectEqual(ErrorReset.append, serverErrorReset(.unauthorized_send_private)); try std.testing.expectEqualStrings("not authorized to sync private content", send_private_unauthorized_text); } test "sync §7.5 r9: a private igot HASH 1 from an unprivileged peer is not an error" { try std.testing.expect(!receivePrivateIgot(true).is_error); try std.testing.expect(!receivePrivateIgot(false).is_error); } test "sync §7.5 r10: the receiver does not create a phantom for a private igot HASH" { try std.testing.expect(!receivePrivateIgot(true).create_phantom); try std.testing.expect(!receivePrivateIgot(false).create_phantom); } test "sync §7.5 r11: an already-held phantom is marked private on a private igot HASH" { try std.testing.expect(receivePrivateIgot(true).mark_phantom_private); try std.testing.expect(!receivePrivateIgot(false).mark_phantom_private); } test "sync §7.5 r12: the receiver records the peer holds HASH and continues" { try std.testing.expect(receivePrivateIgot(true).record_peer_has); try std.testing.expect(receivePrivateIgot(false).record_peer_has); } test "sync §9.2: igot HASH is public; igot HASH 1 is the private-flag form" { // §A.3 Scenario 4 round-1 reply, 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 L1434-1437 vs L1465-1466 r1: admission and integrity are distinct mechanisms (not conflated)" { // Under .sha1 policy a 40-hex name is admitted regardless of content — // admission never inspects bytes, so a corrupt payload still admits. var buf: [64]u8 = undefined; const good_content = "distinct-axes-content"; const sha1_name = artifact.artifactName(.sha1, good_content, &buf); var name_copy: [40]u8 = undefined; @memcpy(&name_copy, sha1_name); try std.testing.expectEqual(Admission.admit, admitInbound(.sha1, .none, &name_copy)); try std.testing.expect(!artifact.verify(&name_copy, "corrupted-different-bytes")); // Under .shun_sha1 a valid SHA3 name both admits AND verifies — the two // decisions are separable, not fused into one predicate. var buf2: [64]u8 = undefined; const sha3_name = artifact.artifactName(.sha3_256, good_content, &buf2); var name3_copy: [64]u8 = undefined; @memcpy(&name3_copy, sha3_name); try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, .none, &name3_copy)); try std.testing.expect(artifact.verify(&name3_copy, good_content)); } test "sync §7.7 L1428-1432/L1436-1437 r2: shun-sha1 rejects an inbound 40-hex SHA1 name before storage (rejection)" { const sha1_name = "a" ** 40; const sha3_name = "a" ** 64; try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, .none, sha1_name)); try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, .none, sha3_name)); } test "sync §7.7 L1434-1437: the admission PREDICATE takes no card-type and no clone input — the whole asymmetry lives at the call sites" { // "Admission applies to the received target HASH." The decision function's // ONLY inputs are the repository's policy, the repository's table, and the // name; it has no `Operator` and no clone-context parameter, so `file` and // `cfile` cannot be given different answers HERE. // // That is deliberately NOT the same claim as "the gate applies identically // at both sites": L1437-1440 has a non-clone `file` discard and a clone // `file` BYPASS, while L1440-1444 has a `cfile` discard even in clone. The // three call-site behaviours are pinned by the L1437-1440 / L1440 / L1440-1444 // tests further down this file; what is pinned here is that the shared // predicate carries none of it. const params = @typeInfo(@TypeOf(admitInbound)).@"fn".params; try std.testing.expectEqual(@as(usize, 3), params.len); try std.testing.expectEqual(artifact.HashPolicy, params[0].type.?); try std.testing.expectEqual(sync.ShunSet, params[1].type.?); try std.testing.expectEqual([]const u8, params[2].type.?); const sha1_name = "a" ** 40; try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, .none, sha1_name)); } test "sync §7.7 L1434-1435: an out-of-domain name WIDTH is answered `admit`, not asserted — a peer chooses this token" { // `parseFileCard`/`parseCfileCard` validate arity and the numeric fields but // NOT the hash width, so a peer can put any token in the HASH position and it // reaches the predicate verbatim. Neither half can fire on such a name: // shun-sha1 keys on a SHA1 NAME, and L1435-1437 keys the table on the // COMPLETE name. An assert here would be a peer-triggered panic in a // Debug/ReleaseSafe build (and would vanish in a release one). var shun = FixtureShunSet{ .names = &.{s77_target} }; for ([_][]const u8{ "", "abc", "-", "a" ** 39, "a" ** 41, "a" ** 65 }) |odd| { try std.testing.expectEqual(Admission.admit, admitInbound(.shun_sha1, shun.shunSet(), odd)); } } test "sync §7.7 L1434-1435 / §7.1: a `file` card whose HASH is not a hash width reaches the receiver without panicking" { // The wire route into the guard above: a short HASH token frames a perfectly // legal payload, so nothing upstream of admission rejects it. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var shun = FixtureShunSet{ .names = &.{s77_target} }; var client = Client{ .operation = .pull, .state = .exchange, .hash_policy = .shun_sha1, .shun = shun.shunSet(), }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), "file abc 3\nxyz"); // Admitted, so the store layer saw it — whatever it already did with an // out-of-domain name is unchanged by §7.7. try std.testing.expect(ms.store().lookup("abc") != null); } test "sync §7.7 L1428-1432 distinction: admission is governed by the configured hash policy, not content" { const sha1_name = "a" ** 40; const admitting_policies = [_]artifact.HashPolicy{ .sha1, .auto, .sha3, .sha3_only }; for (admitting_policies) |p| { try std.testing.expectEqual(Admission.admit, admitInbound(p, .none, sha1_name)); } try std.testing.expectEqual(Admission.reject, admitInbound(.shun_sha1, .none, sha1_name)); } test "sync §7.7 L1465-1466 r5: integrity recomputes over reconstructed bytes, not the wire delta payload" { const source = "the quick brown fox jumps over the lazy dog, repeatedly and at length"; const target = "the quick brown fox jumps over the lazy dog, repeatedly and at length, plus more"; const d = try delta.create(std.testing.allocator, source, target); defer std.testing.allocator.free(d); var buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, target, &buf); var name_copy: [64]u8 = undefined; @memcpy(&name_copy, name); const reconstructed = try delta.apply(std.testing.allocator, source, d); defer std.testing.allocator.free(reconstructed); try std.testing.expect(artifact.verify(&name_copy, reconstructed)); try std.testing.expect(!artifact.verify(&name_copy, d)); // wire delta bytes, not reconstructed content } test "sync §7.7 L1466-1471 r6: pre-storage integrity on non-clone file cards; commit-time on all paths" { try std.testing.expectEqual(IntegritySchedule{ .pre_store = true, .at_commit = true }, integritySchedule(.file, false)); try std.testing.expect(!integritySchedule(.file, true).pre_store); const ops = [_]Operator{ .file, .cfile }; const paths = [_]bool{ false, true }; for (ops) |op| { for (paths) |clone| { try std.testing.expect(integritySchedule(op, clone).at_commit); } } } test "sync §7.7 L1467-1471 r7: cfile pre-storage integrity does not apply in transfer; commit-time still applies" { try std.testing.expectEqual(IntegritySchedule{ .pre_store = false, .at_commit = true }, integritySchedule(.cfile, false)); try std.testing.expectEqual(IntegritySchedule{ .pre_store = false, .at_commit = true }, integritySchedule(.cfile, true)); } test "sync §7.3: uvfile parse takes no hash-policy dependency" { // A HASH token that is neither 40 nor 64 lowercase-hex (not a legal // artifact-hash form) still parses: §7.3-r1 places uvfile outside // [FossilArtifact]'s hash-policy rules; only verifyUvContent (r13) // ever judges HASH against content. const card = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 NOT-A-HASH 0 4")); try std.testing.expectEqualStrings("NOT-A-HASH", card.hash); } test "sync §7.3: uvfile/uvigot/uvgimme card grammar fields" { // §7.3-r2: uvfile-card = "uvfile" SP fossilized SP decimal SP // uv-hash-field SP decimal SP decimal LF. const file = try parseUvfileCard(tokenizeCard("uvfile a.txt 1700000000 " ++ test_hash_a ++ " 12 0")); try std.testing.expectEqualStrings("a.txt", file.name); try std.testing.expectEqual(@as(i64, 1700000000), file.mtime); try std.testing.expectEqualStrings(test_hash_a, file.hash); try std.testing.expectEqual(@as(u32, 12), file.size); try std.testing.expectEqual(@as(u32, 0), file.flags); // uvigot-card = "uvigot" SP fossilized SP decimal SP uv-hash-field // SP decimal LF. const igot = try parseUvigotCard(tokenizeCard("uvigot a.txt 1700000000 " ++ test_hash_a ++ " 12")); try std.testing.expectEqualStrings("a.txt", igot.name); try std.testing.expectEqual(@as(i64, 1700000000), igot.mtime); try std.testing.expectEqualStrings(test_hash_a, igot.hash); try std.testing.expectEqual(@as(u32, 12), igot.size); // uvgimme-card = "uvgimme" SP fossilized LF. const gimme = try parseUvgimmeCard(tokenizeCard("uvgimme a.txt")); try std.testing.expectEqualStrings("a.txt", gimme.name); } test "sync §7.3: NAME is kept fossilize-escaped, not decoded" { // "a\sb" is the fossilize encoding of "a b" (§4.3, \s = SP); the // uvfile layer keeps the raw escaped token (r3) -- decoding is the // caller's job via artifact.decodeEscapes, not this parser's. const card = try parseUvfileCard(tokenizeCard("uvfile a\\sb 100 - 0 1")); try std.testing.expectEqualStrings("a\\sb", card.name); } test "sync §7.3: MTIME is decimal seconds since the epoch" { const card = try parseUvfileCard(tokenizeCard("uvfile a.txt 1700000000 - 0 1")); try std.testing.expectEqual(@as(i64, 1700000000), card.mtime); } test "sync §7.3: HASH is the content hash or literal -" { const with_hash = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 " ++ test_hash_a ++ " 0 0")); try std.testing.expectEqualStrings(test_hash_a, with_hash.hash); const deletion = try parseUvfileCard(tokenizeCard("uvfile a.txt 100 - 0 1")); try std.testing.expectEqualStrings("-", deletion.hash); } test "sync §7.3: SIZE is the following payload byte count" { const body = "uvfile a.txt 100 " ++ test_hash_a ++ " 5 0\nhellotail"; const line = try readLine(body, 0); const card = try parseUvfileCard(tokenizeCard(line.bytes)); try std.testing.expectEqual(@as(u32, 5), card.size); const payload = try consumeUvfilePayload(body, line.next, card); try std.testing.expectEqualStrings("hello", payload.bytes); } test "sync §7.3 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, .unrestricted); // Post-basis: the queue entry is gone AND HASH now holds reconstructed // content, so both halves of the disjunction still exclude it from gimme. try std.testing.expect(!queue.hasTarget(test_hash_a)); const hash_rid = try store.intern(test_hash_a); try std.testing.expect(store.content(hash_rid) != null); try std.testing.expectEqualStrings(hash_bytes, store.content(hash_rid).?); try std.testing.expect(!(store.content(hash_rid) == null and !queue.hasTarget(test_hash_a) and gimmeEligible(elig_clear))); } test "sync §9.3: 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 and is NOT shunned" { // §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. // // §7.7 L1444-1447 opens "For an admitted target whose DELTASRC name is in the // repository SHUN TABLE" — the non-error carve-out is scoped to that cause of // absence and to no other. Here the basis is simply unknown, so the fault // stands. The shunned-source case is the separate §7.7 L1444-1450 test below. 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()); } // --------------------------------------------------------------------------- // Section 7.7 L1434-1463 — receive-side target admission. "Admission applies to // the received target HASH, not independently to DELTASRC. A target is shunned // for admission when its complete name is in the repository shun table or when // shun-sha1 applies to its SHA1 name. A non-clone file whose target is shunned // is consumed and discarded after its payload and pending private marker; it // emits no error, does not set content-received, and makes no repository // mutation. A clone file bypasses this combined target check. For cfile, the // private-admission behavior of Section 7.5 runs first; if processing // continues, a shunned target is consumed and discarded after its payload even // in clone, and its raw parser artifact-card count nevertheless increments // after handler invocation." // // The asymmetry between `file` in clone and `cfile` in clone is DELIBERATE and // is what L1440-1442 says; it is the pair of tests below that pins it. // --------------------------------------------------------------------------- /// A 64-hex (SHA3) target name the fixture shun table names, and two siblings. const s77_target = "d" ** 64; const s77_other = "e" ** 64; /// 22 bytes — the `file` SIZE the tests below frame with. const s77_payload = "shunned artifact bytes"; test "sync §7.7 L1437-1440: a non-clone file card whose target is shunned is consumed and discarded, mutating nothing" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var shun = FixtureShunSet{ .names = &.{s77_target} }; var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() }; defer client.deinit(gpa); // The trailing igot is the instrument for "consumed EXACTLY SIZE bytes": // it is only reached if the cursor resumed at the byte after the payload. const body = "file " ++ s77_target ++ " 22\n" ++ s77_payload ++ "igot " ++ s77_other ++ "\n"; const obs = try client.consumeReply(gpa, ms.store(), body); // "makes no repository mutation" — not even the intern a `put` would do. try std.testing.expect(ms.store().lookup(s77_target) == null); // "does not set content-received". try std.testing.expect(!obs.versioned_content_delivered); try std.testing.expect(!obs.artifact_received_this_round); // "it emits no error" — at this receiver an error is `abort()`, i.e. the // FatalProtocolError the `try` above would have surfaced. Processing // continued instead, and the next card landed. try std.testing.expect(ms.store().lookup(s77_other) != null); } test "sync §7.7 L1440: a CLONE file card bypasses the combined target check — the shunned target is admitted and stored" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var shun = FixtureShunSet{ .names = &.{s77_target} }; var client = Client{ .operation = .clone, .state = .clone_round0, .shun = shun.shunSet() }; defer client.deinit(gpa); const body = "clone_seqno 0\nfile " ++ s77_target ++ " 22\n" ++ s77_payload; const obs = try client.consumeReply(gpa, ms.store(), body); const rid = ms.store().lookup(s77_target) orelse return error.TestUnexpectedResult; try std.testing.expect(ms.store().held(rid)); try std.testing.expectEqualStrings(s77_payload, ms.store().content(rid).?); try std.testing.expect(obs.versioned_content_delivered); } test "sync §7.7 L1440-1444: a cfile whose target is shunned is discarded EVEN IN CLONE, and the artifact-card count still increments" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const plain = "the compressed-form artifact this repository has shunned"; const container = try zlibContainerEncode(gpa, plain, .default); defer gpa.free(container); var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "clone_seqno 0\n"); const head = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ s77_target, plain.len, container.len }); defer gpa.free(head); try body.appendSlice(gpa, head); try body.appendSlice(gpa, container); try body.append(gpa, '\n'); // §5.1 optional trailing-LF padding var shun = FixtureShunSet{ .names = &.{s77_target} }; var client = Client{ .operation = .clone, .state = .clone_round0, .shun = shun.shunSet() }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body.items); try std.testing.expect(ms.store().lookup(s77_target) == null); try std.testing.expect(!obs.versioned_content_delivered); try std.testing.expect(!obs.artifact_received_this_round); // L1443-1444 — "its raw parser artifact-card count nevertheless increments // after handler invocation": the §3.7(e) clone-continuation counter is a RAW // handler count, so a discarded cfile still advances the clone. try std.testing.expect(obs.artifact_count_grew); } test "sync §7.7 L1440-1442: for cfile the §7.5 private-admission behavior runs FIRST" { // The two arms would both discard, so the discriminator is WHICH ONE did: // the §7.5 arm records `private_content_discarded`, the §7.7 arm records // nothing. Run the shun check first and that flag goes false. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const plain = "a private artifact whose name is also in the shun table"; const container = try zlibContainerEncode(gpa, plain, .default); defer gpa.free(container); var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "private\n"); const head = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ s77_target, plain.len, container.len }); defer gpa.free(head); try body.appendSlice(gpa, head); try body.appendSlice(gpa, container); try body.append(gpa, '\n'); var shun = FixtureShunSet{ .names = &.{s77_target} }; // private_granted false — §7.5 L2383-2385 discards for lack of the grant. var client = Client{ .operation = .sync, .state = .exchange, .shun = shun.shunSet() }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body.items); try std.testing.expect(obs.private_content_discarded); try std.testing.expect(ms.store().lookup(s77_target) == null); } test "sync §7.7 L1444-1450: an admitted target whose DELTASRC is shunned reconstructs from a held basis" { // "the receiver does not apply admission independently to that source: // content still held locally MAY serve as the delta basis". zit lands this // with NO carve-out code: admission is simply never asked about DELTASRC. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const basis_plain = "the basis artifact bytes, long enough that a forward delta over them is compact"; const target_plain = basis_plain ++ " plus a small appended mutation"; var nb: [64]u8 = undefined; const basis_name = artifact.artifactName(.sha3_256, basis_plain, &nb); var nt: [64]u8 = undefined; const target_name = artifact.artifactName(.sha3_256, target_plain, &nt); _ = try ms.store().put(basis_name, basis_plain); const raw = try delta.create(gpa, basis_plain, target_plain); defer gpa.free(raw); var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); const head = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n", .{ target_name, basis_name, raw.len }); defer gpa.free(head); try body.appendSlice(gpa, head); try body.appendSlice(gpa, raw); var shun = FixtureShunSet{ .names = &.{basis_name} }; var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() }; defer client.deinit(gpa); _ = try client.consumeReply(gpa, ms.store(), body.items); const rid = ms.store().lookup(target_name) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings(target_plain, ms.store().content(rid).?); } test "sync §7.7 L1447-1450: with the shunned DELTASRC unavailable, ordinary dangling-delta handling retains the source phantom and §3.3 never gimme's it" { const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const basis_plain = "the basis artifact bytes, long enough that a forward delta over them is compact"; const target_plain = basis_plain ++ " plus a small appended mutation"; var nb: [64]u8 = undefined; const basis_name = artifact.artifactName(.sha3_256, basis_plain, &nb); var nt: [64]u8 = undefined; const target_name = artifact.artifactName(.sha3_256, target_plain, &nt); const raw = try delta.create(gpa, basis_plain, target_plain); defer gpa.free(raw); var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); const head = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n", .{ target_name, basis_name, raw.len }); defer gpa.free(head); try body.appendSlice(gpa, head); try body.appendSlice(gpa, raw); var shun = FixtureShunSet{ .names = &.{basis_name} }; var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() }; defer client.deinit(gpa); const obs = try client.consumeReply(gpa, ms.store(), body.items); // "ordinary dangling-delta handling creates or retains the source phantom" try std.testing.expect(obs.phantom_created); const src_rid = ms.store().lookup(basis_name) orelse return error.TestUnexpectedResult; try std.testing.expect(!ms.store().held(src_rid)); // "but the Section 3.3 scan excludes it, so it is not requested." var buf: std.ArrayListUnmanaged(u8) = .empty; defer buf.deinit(gpa); const request = try client.buildRequest(gpa, ms.store(), &buf); try std.testing.expect(!hasGimmeCard(request, basis_name)); } test "sync §7.7 L1434-1437: shun-sha1 is the OTHER half of one combined predicate, not a second check" { // "A target is shunned for admission when its complete name is in the // repository shun table OR when shun-sha1 applies to its SHA1 name." An // empty shun table plus shun-sha1 still discards a 40-hex target. const gpa = std.testing.allocator; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = Client{ .operation = .pull, .state = .exchange, .hash_policy = .shun_sha1 }; defer client.deinit(gpa); const body = "file " ++ test_hash_a ++ " 22\n" ++ s77_payload ++ "igot " ++ s77_other ++ "\n"; const obs = try client.consumeReply(gpa, ms.store(), body); try std.testing.expect(ms.store().lookup(test_hash_a) == null); try std.testing.expect(!obs.versioned_content_delivered); try std.testing.expect(ms.store().lookup(s77_other) != null); // A 64-hex target under the same policy is admitted: the SHA1 half of the // predicate is keyed on the NAME's algorithm, never on the card. var ms2 = repostate.MemStore.init(gpa); defer ms2.deinit(); var c2 = Client{ .operation = .pull, .state = .exchange, .hash_policy = .shun_sha1 }; defer c2.deinit(gpa); _ = try c2.consumeReply(gpa, ms2.store(), "file " ++ s77_target ++ " 22\n" ++ s77_payload); try std.testing.expect(ms2.store().lookup(s77_target) != null); } test "sync §7.7 L1437-1440: a server push-receive discards a shunned pushed file — no store mutation and no error card" { const gpa = std.testing.allocator; const request = "push " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\n" ++ "file " ++ s77_target ++ " 22\n" ++ s77_payload; // Unshunned: the server stores it (the behavior the admission check changes). { var ss = repostate.MemStore.init(gpa); defer ss.deinit(); const server = Server{ .version = h2_server_version }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); _ = try server.respond(gpa, ss.store(), request, &out); const rid = ss.store().lookup(s77_target) orelse return error.TestUnexpectedResult; try std.testing.expect(ss.store().held(rid)); } // Shunned: server push-receive is always non-clone, so the discard arm runs. { var ss = repostate.MemStore.init(gpa); defer ss.deinit(); var shun = FixtureShunSet{ .names = &.{s77_target} }; const server = Server{ .version = h2_server_version, .shun = shun.shunSet() }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ss.store(), request, &out); try std.testing.expect(ss.store().lookup(s77_target) == null); // "it emits no error" — literally, over the wire this time. try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null); } } test "sync §7.7 L1437-1440: a server push-receive of a shunned target in DELTA form emits no error either — the gate is above the reconstruction, not on the store" { // The full form never reaches a fault, so gating only the pass-2 `put` looks // correct until the target arrives as `file HASH DELTASRC SIZE`. Then the // scan reconstructs FIRST, and a basis this repository does not hold turns a // discard §7.7 requires to be silent into `error malformed file line`. The // absent basis is the COMMON case for a shunned target. L1437-1440 — // "consumed and discarded after its payload ...; it emits no error" — draws // no distinction between the two forms of the card. const gpa = std.testing.allocator; // Deliberately NOT a well-formed delta and against a basis nothing holds: // both would fault today, and neither may be observable through the reply. const request = "push " ++ h2_servercode ++ " " ++ h2_projectcode ++ "\n" ++ "file " ++ s77_target ++ " " ++ s77_other ++ " 4\n" ++ "junk"; var ss = repostate.MemStore.init(gpa); defer ss.deinit(); var shun = FixtureShunSet{ .names = &.{s77_target} }; const server = Server{ .version = h2_server_version, .shun = shun.shunSet() }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ss.store(), request, &out); try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null); // "makes no repository mutation" — not even the DELTASRC phantom: admission // is decided before DELTASRC is looked at (L1434-1435 is a TARGET question). try std.testing.expect(ss.store().lookup(s77_target) == null); try std.testing.expect(ss.store().lookup(s77_other) == null); try std.testing.expectEqual(@as(repostate.Rid, 0), ss.store().count()); } test "sync §7.7 L1444-1450: on the SERVER, an admitted target whose shunned DELTASRC is unresolvable is dangling-handled — source phantom kept, never gimme'd, no error" { // "For an admitted target whose DELTASRC name is in the repository shun // table, the receiver does not apply admission independently to that source: // content still held locally MAY serve as the delta basis; if the content is // unavailable, ordinary dangling-delta handling creates or retains the source // phantom, but the Section 3.3 scan excludes it, so it is not requested." // // The basis is unavailable, so the second branch is the one that runs. (In // the CLI's served store a shunned name is additionally masked out of the // by-name index, which is how this arrives in production; the fixture reaches // the same branch by simply not holding it.) Two things must hold together: // no error card (the whole round would otherwise be replaced by one), and the // phantom exists yet is excluded from the reply's §3.3 L527-530 gimme scan. const gpa = std.testing.allocator; const basis = "a shunned basis the served store will not resolve by name"; const target = "a shunned basis the served store will not resolve by name, plus a tail"; const d = try delta.create(gpa, basis, target); defer gpa.free(d); var tbuf: [64]u8 = undefined; const target_hash = artifact.artifactName(.sha3_256, target, &tbuf); var bbuf: [64]u8 = undefined; const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf); var ss = repostate.MemStore.init(gpa); defer ss.deinit(); var shun = FixtureShunSet{ .names = &.{basis_hash} }; const server = Server{ .version = h2_server_version, .shun = shun.shunSet() }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d }); defer gpa.free(request); const reply = try server.respond(gpa, ss.store(), request, &out); // Not an error round. try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null); // The target is ADMITTED but unresolvable, so it does not land. try std.testing.expect(ss.store().lookup(target_hash) == null); // The SOURCE PHANTOM was created even though the source is shunned — // admission is not applied independently to DELTASRC. const src_rid = ss.store().lookup(basis_hash) orelse return error.TestUnexpectedResult; try std.testing.expect(!ss.store().held(src_rid)); // §3.3 L527-530 — but the reply never begs for it. try std.testing.expect(std.mem.indexOf(u8, reply, basis_hash) == null); } test "sync §7.7 L1444-1447 scope: the non-error dangling path is for a SHUNNED source only — an ordinary absent basis keeps the §7.4/§3.8 fault" { // L1444-1447 opens "For an admitted target whose DELTASRC name is in the // repository shun table". The two runs below differ in EXACTLY that: same // request bytes, same (empty) store, only the shun table changes. A receiver // that widened the carve-out to every absent basis would answer both the same // way, and the §7.4/§3.8 fault for an unknown, truncated, or attacker-chosen // DELTASRC would be gone. const gpa = std.testing.allocator; const basis = "the basis neither run holds, so both take the BasisAbsent arm"; const target = "the basis neither run holds, so both take the BasisAbsent arm, plus tail"; const d = try delta.create(gpa, basis, target); defer gpa.free(d); var tbuf: [64]u8 = undefined; const target_hash = artifact.artifactName(.sha3_256, target, &tbuf); var bbuf: [64]u8 = undefined; const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf); const request = try std.fmt.allocPrint(gpa, "push {s} {s}\nfile {s} {s} {d}\n{s}", .{ h2_servercode, h2_projectcode, target_hash, basis_hash, d.len, d }); defer gpa.free(request); // (1) DELTASRC not in the shun table: the fault stands. { var ss = repostate.MemStore.init(gpa); defer ss.deinit(); const server = Server{ .version = h2_server_version }; // ShunSet.none var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ss.store(), request, &out); try std.testing.expect(std.mem.startsWith(u8, reply, "error malformed file")); try std.testing.expectEqual(@as(usize, 0), ss.store().count()); // not even a phantom } // (2) The SAME bytes with DELTASRC in the shun table: L1447-1450's ordinary // dangling-delta handling, no error card, source phantom retained. { var ss = repostate.MemStore.init(gpa); defer ss.deinit(); var shun = FixtureShunSet{ .names = &.{basis_hash} }; const server = Server{ .version = h2_server_version, .shun = shun.shunSet() }; var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); const reply = try server.respond(gpa, ss.store(), request, &out); try std.testing.expect(std.mem.indexOf(u8, reply, "error ") == null); const src_rid = ss.store().lookup(basis_hash) orelse return error.TestUnexpectedResult; try std.testing.expect(!ss.store().held(src_rid)); } } test "sync §7.7 L1437-1440 / §3.3 L518-525: a shun applied BETWEEN rounds stops a deferred delta's target from landing on completion" { // "A non-clone file whose target is shunned ... makes no repository // mutation." The card arms alone cannot carry that clause for a DELTA: a // dangling delta is retained on one round and its target is STORED on a later // one, out of the completion path, where no card arm runs. §3.3 L518-525 // makes the shun table durable and locally mutable, and the scan re-probes it // every round, so the completion must re-ask too. const gpa = std.testing.allocator; const basis = "the deferred delta's basis, delivered on the round after the shun"; const target = "the deferred delta's basis, delivered on the round after the shun, plus tail"; const d = try delta.create(gpa, basis, target); defer gpa.free(d); var tbuf: [64]u8 = undefined; const target_hash = artifact.artifactName(.sha3_256, target, &tbuf); var bbuf: [64]u8 = undefined; const basis_hash = artifact.artifactName(.sha3_256, basis, &bbuf); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); // Gated: the target is not yet shunned when the delta is retained. var shun = FixtureShunSet{ .names = &.{target_hash}, .gated = true }; var client = Client{ .operation = .pull, .state = .exchange, .shun = shun.shunSet() }; defer client.deinit(gpa); // Round 1 — the delta arrives dangling and is retained (§7.4/§3.3). const d_body = try std.fmt.allocPrint(gpa, "file {s} {s} {d}\n{s}", .{ target_hash, basis_hash, d.len, d }); defer gpa.free(d_body); _ = try client.consumeReply(gpa, ms.store(), d_body); try std.testing.expect(client.pending.hasTarget(target_hash)); // admitted at receipt // The operator shuns the TARGET between rounds. shun.armed = true; // Round 2 — the basis arrives. It lands (it is not shunned), but the target // it would complete does not: §7.7 makes no repository mutation for it. const basis_body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ basis_hash, basis.len, basis }); defer gpa.free(basis_body); _ = try client.consumeReply(gpa, ms.store(), basis_body); try std.testing.expect(ms.store().content(ms.store().lookup(basis_hash).?) != null); // basis stored // The target never becomes held, and the queue entry is gone (freed, not leaked). if (ms.store().lookup(target_hash)) |rid| try std.testing.expect(!ms.store().held(rid)); try std.testing.expectEqual(@as(usize, 0), client.pending.itemCount()); try std.testing.expect(client.state != .aborted); // §7.7 emits no error } test "sync §7.7 L1437-1444: TargetAdmission.admits is the predicate PLUS the two L1440 card rules — a clone `file` bypasses, a clone `cfile` does not" { // `admitInbound` deliberately carries no card type and no clone context // (pinned above). `TargetAdmission` is where the draft's two rules attach, so // a landing path far from any card arm — the deferred queue's completion — // can re-ask the same question the arms asked. Both rules in one table: // L1437-1440 non-clone `file` shunned -> discard // L1440 clone `file` shunned -> BYPASS, admitted // L1440-1444 either `cfile` shunned -> discard, even in clone const shunned = "d" ** 64; const clean = "e" ** 64; var shun = FixtureShunSet{ .names = &.{shunned} }; const outside_clone = sync.TargetAdmission{ .shun = shun.shunSet(), .clone = false }; const in_clone = sync.TargetAdmission{ .shun = shun.shunSet(), .clone = true }; try std.testing.expect(!outside_clone.admits(shunned, false)); // non-clone file: discard try std.testing.expect(in_clone.admits(shunned, false)); // clone file: BYPASS try std.testing.expect(!outside_clone.admits(shunned, true)); // cfile: discard try std.testing.expect(!in_clone.admits(shunned, true)); // cfile in clone: still discard // An unshunned target is admitted in all four combinations — the rules gate // only on a shunned name. try std.testing.expect(outside_clone.admits(clean, false)); try std.testing.expect(in_clone.admits(clean, false)); try std.testing.expect(outside_clone.admits(clean, true)); try std.testing.expect(in_clone.admits(clean, true)); // §3.3 L523-525 — the identity: an empty table under `auto` admits everything, // so every call site with no policy to express behaves as before admission // existed. try std.testing.expect(sync.TargetAdmission.unrestricted.admits(shunned, true)); }