Artifact 44a3b4492f0c3b76263d0008323f4946a7845c9b9135788f450c4001fdef52b7:
- File
internal/xfer/codec_test.go
— part of check-in
[d33a37fffd]
at
2026-10-12 23:24:45
on branch main
— fix(sync): apply received unversioned files as fossil does (#308) (#361)
fossil's xfer_accept_unversioned_file stores a received file under the HASH its card gave, takes only the mtime when the content is the same and newer (status 2), and stores an omitted payload as NULL with size 0; send_unversioned_file sends nothing for a name it does not hold; and the client seeds uv_tosend from rows whose hash is not NULL (sections 7.3, 8.5.1). Both sides stored a received file under a hash computed by local policy and dropped status 2 and omitted-content cards; the server answered an absent name with a tombstone; the client queued its tombstones.
uv.Receive now owns the acceptance rule, which the server and the client share instead of each re-deriving it from uv.Read, uv.Status and uv.Write. (user: [email protected], size: 44552) [annotate] [blame] [check-ins using]
package xfer import ( "bufio" "bytes" "compress/zlib" "encoding/binary" "fmt" "io" "strconv" "strings" "testing" ) // helper: encode a card, then decode it back, returning the decoded card. func roundTrip(t *testing.T, c Card) Card { t.Helper() var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard(%T): %v", c, err) } r := bufio.NewReader(&buf) got, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard after encode(%T): %v (wire: %q)", c, err, buf.String()) } return got } // --- Task 2: Simple (non-payload) card round-trips --- func TestRoundTrip_IGot(t *testing.T) { c := &IGotCard{UUID: "abc123def456"} got := roundTrip(t, c).(*IGotCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if got.IsPrivate { t.Error("IsPrivate should be false") } } func TestRoundTrip_IGotPrivate(t *testing.T) { c := &IGotCard{UUID: "abc123def456", IsPrivate: true} got := roundTrip(t, c).(*IGotCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if !got.IsPrivate { t.Error("IsPrivate should be true") } } func TestRoundTrip_Gimme(t *testing.T) { c := &GimmeCard{UUID: "deadbeef0123"} got := roundTrip(t, c).(*GimmeCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } } func TestRoundTrip_Push(t *testing.T) { // Both codes required: "push <server-code> <project-code>\n" c := &PushCard{ProjectCode: "proj1", ServerCode: "srv1"} got := roundTrip(t, c).(*PushCard) if got.ProjectCode != c.ProjectCode || got.ServerCode != c.ServerCode { t.Errorf("Push = %+v, want %+v", got, c) } } func TestRoundTrip_Pull(t *testing.T) { // Both codes required on pull: "pull <server-code> <project-code>\n" c := &PullCard{ProjectCode: "proj2", ServerCode: "srv2"} got := roundTrip(t, c).(*PullCard) if got.ProjectCode != c.ProjectCode || got.ServerCode != c.ServerCode { t.Errorf("Pull = %+v, want %+v", got, c) } } func TestRoundTrip_Cookie(t *testing.T) { c := &CookieCard{Value: "session-abc-123"} got := roundTrip(t, c).(*CookieCard) if got.Value != c.Value { t.Errorf("Value = %q, want %q", got.Value, c.Value) } } func TestRoundTrip_ReqConfig(t *testing.T) { c := &ReqConfigCard{Name: "css"} got := roundTrip(t, c).(*ReqConfigCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } } func TestRoundTrip_Private(t *testing.T) { c := &PrivateCard{} got := roundTrip(t, c).(*PrivateCard) if got.Type() != CardPrivate { t.Error("wrong type") } } func TestRoundTrip_CloneNoArgs(t *testing.T) { c := &CloneCard{} // Version=0, SeqNo=0 -> "clone\n" got := roundTrip(t, c).(*CloneCard) if got.Version != 0 || got.SeqNo != 0 { t.Errorf("Clone = %+v, want {0 0}", got) } } func TestRoundTrip_CloneWithArgs(t *testing.T) { c := &CloneCard{Version: 3, SeqNo: 42} got := roundTrip(t, c).(*CloneCard) if got.Version != 3 || got.SeqNo != 42 { t.Errorf("Clone = {%d %d}, want {3 42}", got.Version, got.SeqNo) } } func TestRoundTrip_CloneSeqNo(t *testing.T) { c := &CloneSeqNoCard{SeqNo: 99} got := roundTrip(t, c).(*CloneSeqNoCard) if got.SeqNo != 99 { t.Errorf("SeqNo = %d, want 99", got.SeqNo) } } func TestRoundTrip_UVGimme(t *testing.T) { c := &UVGimmeCard{Name: "data/config.json"} got := roundTrip(t, c).(*UVGimmeCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } } func TestRoundTrip_PragmaNoValues(t *testing.T) { c := &PragmaCard{Name: "sym-pressure"} got := roundTrip(t, c).(*PragmaCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } if len(got.Values) != 0 { t.Errorf("Values = %v, want empty", got.Values) } } func TestRoundTrip_PragmaWithValues(t *testing.T) { c := &PragmaCard{Name: "sym-pressure", Values: []string{"100", "200"}} got := roundTrip(t, c).(*PragmaCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } if len(got.Values) != 2 || got.Values[0] != "100" || got.Values[1] != "200" { t.Errorf("Values = %v, want [100 200]", got.Values) } } // --- Comment/empty line skipping and unknown cards --- func TestDecode_SkipCommentAndEmptyLines(t *testing.T) { input := "# this is a comment\n\n# another comment\ngimme abc123\n" r := bufio.NewReader(strings.NewReader(input)) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } g, ok := card.(*GimmeCard) if !ok { t.Fatalf("got %T, want *GimmeCard", card) } if g.UUID != "abc123" { t.Errorf("UUID = %q, want %q", g.UUID, "abc123") } } func TestDecode_UnknownCard(t *testing.T) { // An unrecognized operator is a bad command (§5.2): the decoder yields a // BadCommandCard carrying the raw line, not an error. input := "foobar arg1 arg2\n" r := bufio.NewReader(strings.NewReader(input)) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } bad, ok := card.(*BadCommandCard) if !ok { t.Fatalf("got %T, want *BadCommandCard", card) } if bad.Line != input { t.Errorf("Line = %q, want %q", bad.Line, input) } if bad.Type() != CardBadCommand { t.Errorf("Type() = %d, want %d", bad.Type(), CardBadCommand) } } func TestDecode_EOF(t *testing.T) { r := bufio.NewReader(strings.NewReader("")) _, err := DecodeCard(r) if err != io.EOF { t.Errorf("got err = %v, want io.EOF", err) } } func TestDecode_CommentOnlyEOF(t *testing.T) { input := "# only comments\n# no cards\n" r := bufio.NewReader(strings.NewReader(input)) _, err := DecodeCard(r) if err != io.EOF { t.Errorf("got err = %v, want io.EOF", err) } } func TestDecode_MultipleCards(t *testing.T) { input := "gimme aaa\nprivate\nigot bbb\n" r := bufio.NewReader(strings.NewReader(input)) c1, err := DecodeCard(r) if err != nil { t.Fatalf("card 1: %v", err) } if _, ok := c1.(*GimmeCard); !ok { t.Fatalf("card 1: got %T, want *GimmeCard", c1) } c2, err := DecodeCard(r) if err != nil { t.Fatalf("card 2: %v", err) } if _, ok := c2.(*PrivateCard); !ok { t.Fatalf("card 2: got %T, want *PrivateCard", c2) } c3, err := DecodeCard(r) if err != nil { t.Fatalf("card 3: %v", err) } if _, ok := c3.(*IGotCard); !ok { t.Fatalf("card 3: got %T, want *IGotCard", c3) } _, err = DecodeCard(r) if err != io.EOF { t.Errorf("expected io.EOF after all cards, got %v", err) } } // --- Payload card arg validation (formerly stubs) --- // decodeCardStr decodes one card from input, failing the test on an I/O // error. A card the decoder refuses is not an error: it comes back as a // *BadCommandCard or *BadFileCard (§5.2). func decodeCardStr(t *testing.T, input string) Card { t.Helper() card, err := DecodeCard(bufio.NewReader(strings.NewReader(input))) if err != nil { t.Fatalf("DecodeCard(%q): %v", input, err) } return card } // wantBadCommand asserts input decodes to a BadCommandCard quoting it. func wantBadCommand(t *testing.T, input string) { t.Helper() card := decodeCardStr(t, input) c, ok := card.(*BadCommandCard) if !ok { t.Fatalf("decode %q: got %T, want *BadCommandCard", input, card) } if c.Line != input { t.Errorf("decode %q: Line = %q, want %q", input, c.Line, input) } } // wantBadFile asserts input decodes to a BadFileCard carrying msg. func wantBadFile(t *testing.T, input, msg string) { t.Helper() card := decodeCardStr(t, input) c, ok := card.(*BadFileCard) if !ok { t.Fatalf("decode %q: got %T, want *BadFileCard", input, card) } if c.Message != msg { t.Errorf("decode %q: Message = %q, want %q", input, c.Message, msg) } } func TestDecode_FileBadArgCount(t *testing.T) { // "file" with only 1 arg cannot be framed: a bad file line (§5.2). wantBadFile(t, "file abc123\n", "malformed file line") } func TestDecode_CFileBadArgCount(t *testing.T) { wantBadFile(t, "cfile abc123 100\n", "malformed cfile line") } func TestDecode_ConfigBadArgCount(t *testing.T) { // A config with the wrong arity is a bad command: no payload is read. wantBadCommand(t, "config\n") } func TestDecode_UVFileBadArgCount(t *testing.T) { wantBadFile(t, "uvfile name\n", "malformed uvfile line") } // --- Arg count validation --- func TestDecode_IGotBadArgCount(t *testing.T) { // igot needs at least a hash; with none it is a bad command. wantBadCommand(t, "igot\n") } // TestDecode_PushOneArg verifies that "push <code>" (1 arg) is rejected. // Canonical acts on push only at nToken==3, and the two codes are not // interchangeable — the second is the project code, which is the value a // cloning client adopts and a server validates. Guessing which one a one-arg // push meant would put a server code where a project code belongs (#203). func TestDecode_PushOneArg(t *testing.T) { wantBadCommand(t, "push only-one\n") } // TestEncode_PushEmptyCodes verifies that encoding a PushCard with empty // ProjectCode panics — bare "push\n" is rejected by the upstream Fossil C // server, so we prevent it at encode time. func TestEncode_PushEmptyCodes(t *testing.T) { defer func() { if r := recover(); r == nil { t.Error("expected panic when encoding PushCard with empty ProjectCode") } }() var buf bytes.Buffer _ = EncodeCard(&buf, &PushCard{}) } // TestEncode_PullEmptyCodes verifies that encoding a PullCard with empty // codes panics — both project-code and server-code are required by the // upstream Fossil C xfer parser. func TestEncode_PullEmptyCodes(t *testing.T) { defer func() { if r := recover(); r == nil { t.Error("expected panic when encoding PullCard with empty codes") } }() var buf bytes.Buffer _ = EncodeCard(&buf, &PullCard{}) } // TestEncode_PullMissingServerCode verifies that a PullCard with ProjectCode // but no ServerCode panics — Fossil C requires both args on pull. func TestEncode_PullMissingServerCode(t *testing.T) { defer func() { if r := recover(); r == nil { t.Error("expected panic when encoding PullCard with empty ServerCode") } }() var buf bytes.Buffer _ = EncodeCard(&buf, &PullCard{ProjectCode: "proj1"}) } // TestDecode_PushZeroArgs verifies that a bare "push\n" (0 args) is now // rejected by the decoder — it was previously accepted as a Go-only extension // that masked the encoder bug. func TestDecode_PushZeroArgs(t *testing.T) { wantBadCommand(t, "push\n") } // TestDecode_PullOneArg verifies that a single-arg "pull <code>\n" is // rejected — Fossil C requires both project-code and server-code on pull. func TestDecode_PullOneArg(t *testing.T) { wantBadCommand(t, "pull only-one\n") } // TestDecode_PullZeroArgs verifies that a bare "pull\n" is a bad command. func TestDecode_PullZeroArgs(t *testing.T) { wantBadCommand(t, "pull\n") } // TestEncode_PushServerCodeOnlyMissing verifies that encoding a PushCard // without a ServerCode panics. A two-token push is not a card canonical acts // on (it requires nToken==3), so emitting one silently drops the push; a // client with no cached server code sends canonical's placeholder "x". func TestEncode_PushServerCodeMissing(t *testing.T) { defer func() { if recover() == nil { t.Error("expected panic for PushCard with empty ServerCode") } }() var buf bytes.Buffer _ = EncodeCard(&buf, &PushCard{ProjectCode: "proj123"}) } // TestWireFormat_PushBothCodes and its pull twin pin the byte order of the two // codes: server code first, project code second. // // These bytes are the fix for #203. Emitting them the other way round put the // server code where a real fossil server reads the project code — so a clone // adopted the server code as its project code, derived its login signature // from it, and every authenticated sync was refused with "login failed" // whatever the password was. Canonical's own client writes // `blob_appendf(&send, "push %s %s\n", zSCode, zPCode)` (src/xfer.c // client_sync), and a captured fossil 2.28 pull reads: // // pull 49a66cbdf294f973f6b1e5100f04c09b594047cd 2d5573080bf03f15ed58105794cc80c6a51f4c52 // // where the first code is the client repo's own server-code and the second is // the project-code shared by both ends. func TestWireFormat_PushBothCodes(t *testing.T) { c := &PushCard{ProjectCode: "proj1", ServerCode: "srv1"} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard: %v", err) } const want = "push srv1 proj1\n" if buf.String() != want { t.Errorf("wire form = %q, want %q", buf.String(), want) } } func TestWireFormat_PullBothCodes(t *testing.T) { c := &PullCard{ProjectCode: "proj1", ServerCode: "srv1"} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard: %v", err) } const want = "pull srv1 proj1\n" if buf.String() != want { t.Errorf("wire form = %q, want %q", buf.String(), want) } } // TestDecode_PushCanonicalCapture decodes the push card a fossil 2.28 server // actually sent in reply to a clone. The project code is the second argument; // reading the first is what #203 did. func TestDecode_PushCanonicalCapture(t *testing.T) { const ( serverCode = "3405626440c494022b54077b9cf27f0ef9a93316" projectCode = "2d5573080bf03f15ed58105794cc80c6a51f4c52" ) r := bufio.NewReader(strings.NewReader("push " + serverCode + " " + projectCode + "\n")) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } push := card.(*PushCard) if push.ProjectCode != projectCode { t.Errorf("ProjectCode = %q, want %q", push.ProjectCode, projectCode) } if push.ServerCode != serverCode { t.Errorf("ServerCode = %q, want %q", push.ServerCode, serverCode) } } func TestDecode_CloneBadArgCount(t *testing.T) { // clone takes zero or two tokens; one is a bad command. wantBadCommand(t, "clone 3\n") } // TestDecode_CloneSeqNoNonDecimal pins §3.2 and §8.2: NEXT is recorded only // when it is decimal (`decimal = 1*DIGIT`), and a visible but non-decimal // NEXT is a receiver-tolerance exception that leaves the recorded sequence // unchanged without stopping later reply cards. Erroring here would abort a // clone the spec expects to continue, so the card must decode to something // no consumer will record — never a CloneSeqNoCard. func TestDecode_CloneSeqNoNonDecimal(t *testing.T) { for _, arg := range []string{"notanumber", "-1", "+3", "3x"} { r := bufio.NewReader(strings.NewReader("clone_seqno " + arg + "\nprivate\n")) card, err := DecodeCard(r) if err != nil { t.Fatalf("clone_seqno %q: unexpected error %v", arg, err) } if _, isSeq := card.(*CloneSeqNoCard); isSeq { t.Errorf("clone_seqno %q decoded to CloneSeqNoCard; must not be recorded", arg) } // Parsing must continue into the following card. next, err := DecodeCard(r) if err != nil { t.Fatalf("clone_seqno %q: parsing stopped after non-decimal NEXT: %v", arg, err) } if _, ok := next.(*PrivateCard); !ok { t.Errorf("clone_seqno %q: next card = %T, want *PrivateCard", arg, next) } } } // TestDecode_CloneSeqNoDecimalRecorded is the positive half: a digit-only // NEXT is recorded. func TestDecode_CloneSeqNoDecimalRecorded(t *testing.T) { r := bufio.NewReader(strings.NewReader("clone_seqno 0\n")) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } seq, ok := card.(*CloneSeqNoCard) if !ok { t.Fatalf("card = %T, want *CloneSeqNoCard", card) } if seq.SeqNo != 0 { t.Errorf("SeqNo = %d, want 0", seq.SeqNo) } } // TestDecode_CloneSeqNoPresence pins that the decoder distinguishes a bare // `clone` from `clone VERSION SEQNO`, which §8.1 treats differently: the // parsed SeqNo is 0 either way, so only the presence flag separates the // non-fatal case from the fatal one. func TestDecode_CloneSeqNoPresence(t *testing.T) { tests := []struct { line string wantHas bool wantSeq int wantVers int }{ {"clone\n", false, 0, 0}, {"clone 3 0\n", true, 0, 3}, {"clone 3 5\n", true, 5, 3}, {"clone 3 -1\n", false, -1, 3}, // not digit-only: §8.1 fatal withheld } for _, tt := range tests { card, err := DecodeCard(bufio.NewReader(strings.NewReader(tt.line))) if err != nil { t.Fatalf("%q: %v", tt.line, err) } c, ok := card.(*CloneCard) if !ok { t.Fatalf("%q: card = %T, want *CloneCard", tt.line, card) } if c.SeqNoIsDecimal != tt.wantHas || c.SeqNo != tt.wantSeq || c.Version != tt.wantVers { t.Errorf("%q: got SeqNoIsDecimal=%v SeqNo=%d Version=%d, want %v/%d/%d", tt.line, c.SeqNoIsDecimal, c.SeqNo, c.Version, tt.wantHas, tt.wantSeq, tt.wantVers) } } } // TestDecode_CloneUnparseableToken pins issue #95: a VERSION or SEQNO token // that strconv.Atoi cannot parse at all (not just one that fails the §8.1 // digit-only rule) must not fail the whole message. parseClone degrades the // same way parseCloneSeqNo does for clone_seqno -- carrying the raw tokens // forward as an UnknownCard -- so a later card in the same message still // decodes rather than the whole message erroring out on the first Atoi call. func TestDecode_CloneUnparseableToken(t *testing.T) { tests := []struct { name string line string }{ {"non-numeric version", "clone abc 5\n"}, {"non-numeric seqno", "clone 3 abc\n"}, {"both non-numeric", "clone abc xyz\n"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := bufio.NewReader(strings.NewReader(tt.line + "private\n")) card, err := DecodeCard(r) if err != nil { t.Fatalf("%q: unexpected error %v", tt.line, err) } uc, ok := card.(*UnknownCard) if !ok { t.Fatalf("%q: card = %T, want *UnknownCard", tt.line, card) } if uc.Command != "clone" { t.Errorf("%q: Command = %q, want %q", tt.line, uc.Command, "clone") } // Parsing must continue into the following card. next, err := DecodeCard(r) if err != nil { t.Fatalf("%q: parsing stopped after unparseable clone card: %v", tt.line, err) } if _, ok := next.(*PrivateCard); !ok { t.Errorf("%q: next card = %T, want *PrivateCard", tt.line, next) } }) } } // --- Wire format verification --- func TestEncode_WireFormat_IGot(t *testing.T) { var buf bytes.Buffer EncodeCard(&buf, &IGotCard{UUID: "abc"}) if buf.String() != "igot abc\n" { t.Errorf("wire = %q, want %q", buf.String(), "igot abc\n") } } func TestEncode_WireFormat_IGotPrivate(t *testing.T) { var buf bytes.Buffer EncodeCard(&buf, &IGotCard{UUID: "abc", IsPrivate: true}) if buf.String() != "igot abc 1\n" { t.Errorf("wire = %q, want %q", buf.String(), "igot abc 1\n") } } func TestEncode_WireFormat_Private(t *testing.T) { var buf bytes.Buffer EncodeCard(&buf, &PrivateCard{}) if buf.String() != "private\n" { t.Errorf("wire = %q, want %q", buf.String(), "private\n") } } func TestEncode_WireFormat_CloneNoArgs(t *testing.T) { var buf bytes.Buffer EncodeCard(&buf, &CloneCard{}) if buf.String() != "clone\n" { t.Errorf("wire = %q, want %q", buf.String(), "clone\n") } } func TestEncode_WireFormat_CloneWithArgs(t *testing.T) { var buf bytes.Buffer EncodeCard(&buf, &CloneCard{Version: 3, SeqNo: 42}) if buf.String() != "clone 3 42\n" { t.Errorf("wire = %q, want %q", buf.String(), "clone 3 42\n") } } // --- Task 3: Fossil-encoded cards --- func TestRoundTrip_Login(t *testing.T) { c := &LoginCard{User: "test user", Nonce: "nonce123", Signature: "sig456"} got := roundTrip(t, c).(*LoginCard) if got.User != "test user" { t.Errorf("User = %q, want %q", got.User, "test user") } if got.Nonce != "nonce123" || got.Signature != "sig456" { t.Errorf("Login = %+v, want %+v", got, c) } } func TestRoundTrip_LoginFossilEncoding(t *testing.T) { // Verify the wire format uses Fossil encoding for the user field c := &LoginCard{User: "test user", Nonce: "n", Signature: "s"} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wire := buf.String() if wire != "login test\\suser n s\n" { t.Errorf("wire = %q, want %q", wire, "login test\\suser n s\n") } } func TestRoundTrip_ErrorMessage(t *testing.T) { c := &ErrorCard{Message: "not authorized to write"} got := roundTrip(t, c).(*ErrorCard) if got.Message != "not authorized to write" { t.Errorf("Message = %q, want %q", got.Message, "not authorized to write") } } func TestRoundTrip_ErrorFossilEncoding(t *testing.T) { c := &ErrorCard{Message: "not authorized to write"} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wire := buf.String() if wire != "error not\\sauthorized\\sto\\swrite\n" { t.Errorf("wire = %q, want %q", wire, "error not\\sauthorized\\sto\\swrite\n") } } func TestRoundTrip_Message(t *testing.T) { c := &MessageCard{Message: "clone in progress"} got := roundTrip(t, c).(*MessageCard) if got.Message != "clone in progress" { t.Errorf("Message = %q, want %q", got.Message, "clone in progress") } } func TestRoundTrip_MessageFossilEncoding(t *testing.T) { c := &MessageCard{Message: "clone in progress"} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wire := buf.String() if wire != "message clone\\sin\\sprogress\n" { t.Errorf("wire = %q, want %q", wire, "message clone\\sin\\sprogress\n") } } func TestRoundTrip_UVIGot(t *testing.T) { c := &UVIGotCard{ Name: "data/config.json", MTime: 1700000000, Hash: "abc123def456", Size: 4096, } got := roundTrip(t, c).(*UVIGotCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } if got.MTime != c.MTime { t.Errorf("MTime = %d, want %d", got.MTime, c.MTime) } if got.Hash != c.Hash { t.Errorf("Hash = %q, want %q", got.Hash, c.Hash) } if got.Size != c.Size { t.Errorf("Size = %d, want %d", got.Size, c.Size) } } func TestEncode_WireFormat_UVIGot(t *testing.T) { c := &UVIGotCard{Name: "f.txt", MTime: 100, Hash: "abc", Size: 42} var buf bytes.Buffer EncodeCard(&buf, c) if buf.String() != "uvigot f.txt 100 abc 42\n" { t.Errorf("wire = %q, want %q", buf.String(), "uvigot f.txt 100 abc 42\n") } } func TestDecode_UVIGotBadArgCount(t *testing.T) { wantBadCommand(t, "uvigot only-name\n") } func TestDecode_UVIGotBadMTime(t *testing.T) { wantBadCommand(t, "uvigot name notnum hash 42\n") } // Test that an UnknownCard encodes to its wire form, which a receiver then // reads back as a bad command — an unrecognized operator is not round-tripped // as an UnknownCard (§5.2). UnknownCard survives decoding only for the clone // and clone_seqno tolerance exceptions, which have their own tests. func TestRoundTrip_UnknownCard(t *testing.T) { c := &UnknownCard{Command: "newcmd", Args: []string{"x", "y"}} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard: %v", err) } if buf.String() != "newcmd x y\n" { t.Fatalf("wire = %q, want %q", buf.String(), "newcmd x y\n") } if _, ok := decodeCardStr(t, buf.String()).(*BadCommandCard); !ok { t.Errorf("decode %q did not yield a BadCommandCard", buf.String()) } } // Test Fossil encoding with special characters: backslash and newline func TestRoundTrip_ErrorWithBackslash(t *testing.T) { c := &ErrorCard{Message: "path\\to\\file"} got := roundTrip(t, c).(*ErrorCard) if got.Message != c.Message { t.Errorf("Message = %q, want %q", got.Message, c.Message) } } func TestRoundTrip_MessageWithNewline(t *testing.T) { c := &MessageCard{Message: "line1\nline2"} got := roundTrip(t, c).(*MessageCard) if got.Message != c.Message { t.Errorf("Message = %q, want %q", got.Message, c.Message) } } // --- Task 3 additional edge-case tests --- // Verify that login with spaces in user survives a full encode->wire->decode cycle func TestRoundTrip_LoginSpacesInUser(t *testing.T) { c := &LoginCard{User: "john doe", Nonce: "aaa", Signature: "bbb"} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wire := buf.String() // Wire must contain the Fossil-encoded form, NOT raw spaces if !strings.Contains(wire, `john\sdoe`) { t.Errorf("wire %q does not contain fossil-encoded user", wire) } // Decode must recover the original plain-text user r := bufio.NewReader(strings.NewReader(wire)) got, err := DecodeCard(r) if err != nil { t.Fatal(err) } login := got.(*LoginCard) if login.User != "john doe" { t.Errorf("User = %q, want %q", login.User, "john doe") } } func TestDecode_LoginBadArgCount(t *testing.T) { wantBadCommand(t, "login onlyuser\n") } func TestDecode_ErrorBadArgCount(t *testing.T) { // error takes exactly 1 Fossil-encoded token; none is a bad command. wantBadCommand(t, "error\n") } func TestDecode_MessageBadArgCount(t *testing.T) { wantBadCommand(t, "message\n") } func TestDecode_PragmaBadArgCount(t *testing.T) { wantBadCommand(t, "pragma\n") } // UVIGot with large int64 MTime func TestRoundTrip_UVIGotLargeMTime(t *testing.T) { c := &UVIGotCard{Name: "big.bin", MTime: 9999999999, Hash: "deadbeef", Size: 0} got := roundTrip(t, c).(*UVIGotCard) if got.MTime != 9999999999 { t.Errorf("MTime = %d, want 9999999999", got.MTime) } } // --- Task 4: FileCard --- func TestRoundTrip_File(t *testing.T) { c := &FileCard{UUID: "abc123def456", Content: []byte("hello world")} got := roundTrip(t, c).(*FileCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if got.DeltaSrc != "" { t.Errorf("DeltaSrc = %q, want empty", got.DeltaSrc) } if !bytes.Equal(got.Content, c.Content) { t.Errorf("Content = %q, want %q", got.Content, c.Content) } } func TestRoundTrip_FileWithDeltaSrc(t *testing.T) { c := &FileCard{UUID: "abc123", DeltaSrc: "def456", Content: []byte("delta payload")} got := roundTrip(t, c).(*FileCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if got.DeltaSrc != c.DeltaSrc { t.Errorf("DeltaSrc = %q, want %q", got.DeltaSrc, c.DeltaSrc) } if !bytes.Equal(got.Content, c.Content) { t.Errorf("Content = %q, want %q", got.Content, c.Content) } } func TestRoundTrip_FileEmptyContent(t *testing.T) { c := &FileCard{UUID: "abc123", Content: []byte{}} got := roundTrip(t, c).(*FileCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if len(got.Content) != 0 { t.Errorf("Content length = %d, want 0", len(got.Content)) } } func TestEncode_WireFormat_FileNoTrailingNewline(t *testing.T) { c := &FileCard{UUID: "abc123", Content: []byte("data")} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wire := buf.Bytes() // Should be: "file abc123 4\ndata" — no trailing \n expected := []byte("file abc123 4\ndata") if !bytes.Equal(wire, expected) { t.Errorf("wire = %q, want %q", wire, expected) } // Verify no trailing newline if len(wire) > 0 && wire[len(wire)-1] == '\n' { t.Error("file card wire should NOT end with \\n") } } func TestDecode_FileTruncatedPayload(t *testing.T) { // Header says 100 bytes but only 5 bytes follow input := "file abc123 100\nhello" r := bufio.NewReader(strings.NewReader(input)) _, err := DecodeCard(r) if err == nil { t.Error("expected error for truncated file payload") } } // --- Task 5: CFileCard --- func TestRoundTrip_CFile(t *testing.T) { c := &CFileCard{UUID: "abc123def456", Content: []byte("hello compressed world")} got := roundTrip(t, c).(*CFileCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if got.DeltaSrc != "" { t.Errorf("DeltaSrc = %q, want empty", got.DeltaSrc) } if !bytes.Equal(got.Content, c.Content) { t.Errorf("Content = %q, want %q", got.Content, c.Content) } } func TestRoundTrip_CFileWithDeltaSrc(t *testing.T) { c := &CFileCard{UUID: "abc123", DeltaSrc: "def456", Content: []byte("delta compressed payload")} got := roundTrip(t, c).(*CFileCard) if got.UUID != c.UUID { t.Errorf("UUID = %q, want %q", got.UUID, c.UUID) } if got.DeltaSrc != c.DeltaSrc { t.Errorf("DeltaSrc = %q, want %q", got.DeltaSrc, c.DeltaSrc) } if !bytes.Equal(got.Content, c.Content) { t.Errorf("Content = %q, want %q", got.Content, c.Content) } } func TestRoundTrip_CFileLargeContent(t *testing.T) { // 100KB of repeated data — should compress well content := bytes.Repeat([]byte("ABCDEFGHIJ"), 10240) // 100KB c := &CFileCard{UUID: "large123", Content: content} got := roundTrip(t, c).(*CFileCard) if !bytes.Equal(got.Content, content) { t.Errorf("large content mismatch: got %d bytes, want %d", len(got.Content), len(content)) } } // splitCFileWire parses an encoded cfile card into (usize, csize, payload). // It expects the non-delta form: "cfile UUID USIZE CSIZE\n<payload>". func splitCFileWire(t *testing.T, wire []byte) (usize, csize int, payload []byte) { t.Helper() nl := bytes.IndexByte(wire, '\n') if nl < 0 { t.Fatalf("cfile wire has no header newline: %q", wire) } header := string(wire[:nl]) payload = wire[nl+1:] fields := strings.Fields(header) if len(fields) != 4 || fields[0] != "cfile" { t.Fatalf("unexpected cfile header %q", header) } var err error if usize, err = strconv.Atoi(fields[2]); err != nil { t.Fatalf("USIZE parse %q: %v", fields[2], err) } if csize, err = strconv.Atoi(fields[3]); err != nil { t.Fatalf("CSIZE parse %q: %v", fields[3], err) } // fossil pads a payload that does not end in LF with one (§5.1). if len(payload) == csize+1 && payload[csize] == '\n' { payload = payload[:csize] } return usize, csize, payload } // TestEncodeCFile_FossilOnDiskFraming is the regression test for issue #152: // a cfile card's wire payload must be Fossil's on-disk blob format // ([4-byte big-endian uncompressed-size prefix][zlib stream]) -- the exact // bytes blob_compress() produces -- because a real fossil client stores the // cfile payload verbatim as blob.content and later reads it back with // blob_uncompress(), which reads that 4-byte prefix to size its output before // inflating. Emitting bare zlib (no prefix) leaves the client with content its // own decompressor cannot decode, so a clone yields zero usable check-ins. func TestEncodeCFile_FossilOnDiskFraming(t *testing.T) { content := []byte("full artifact content, long enough that zlib actually compresses it") c := &CFileCard{UUID: "abc123def456", Content: content} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard: %v", err) } usize, csize, payload := splitCFileWire(t, buf.Bytes()) if usize != len(content) { t.Fatalf("USIZE = %d, want %d", usize, len(content)) } if csize != len(payload) { t.Fatalf("CSIZE = %d, want payload length %d", csize, len(payload)) } if len(payload) < 4 { t.Fatalf("payload too short for a 4-byte prefix: %d bytes", len(payload)) } gotPrefix := binary.BigEndian.Uint32(payload[:4]) if gotPrefix != uint32(len(content)) { t.Fatalf("4-byte size prefix = %d, want %d (bare zlib with no prefix?)", gotPrefix, len(content)) } // The bytes after the prefix must be a valid zlib stream that inflates to // the content -- exactly what a real fossil client's blob_uncompress reads. zr, err := zlib.NewReader(bytes.NewReader(payload[4:])) if err != nil { t.Fatalf("payload after prefix is not a valid zlib stream: %v", err) } got, err := io.ReadAll(zr) zr.Close() if err != nil { t.Fatalf("zlib read: %v", err) } if !bytes.Equal(got, content) { t.Fatalf("decompressed content mismatch:\n got %q\n want %q", got, content) } } // TestEncodeCFile_DeltaPrefixIsPayloadSize confirms that for a delta cfile the // 4-byte prefix is the size of the (delta) payload being compressed, while the // USIZE header field carries the full reconstructed artifact size -- matching // fossil's blob_compress(&delta) where the prefix is len(delta), not len(full). func TestEncodeCFile_DeltaPrefixIsPayloadSize(t *testing.T) { delta := []byte("delta payload bytes that stand in for a real fossil delta stream") const fullSize = 4096 c := &CFileCard{UUID: "abc123", DeltaSrc: "def456", USize: fullSize, Content: delta} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard: %v", err) } nl := bytes.IndexByte(buf.Bytes(), '\n') if nl < 0 { t.Fatalf("no header newline") } fields := strings.Fields(string(buf.Bytes()[:nl])) // cfile UUID DELTASRC USIZE CSIZE if len(fields) != 5 { t.Fatalf("delta cfile header fields = %v", fields) } if fields[3] != strconv.Itoa(fullSize) { t.Fatalf("USIZE = %s, want %d", fields[3], fullSize) } payload := buf.Bytes()[nl+1:] if len(payload) < 4 { t.Fatalf("payload too short: %d", len(payload)) } if gotPrefix := binary.BigEndian.Uint32(payload[:4]); gotPrefix != uint32(len(delta)) { t.Fatalf("delta prefix = %d, want len(delta)=%d", gotPrefix, len(delta)) } } // TestDecodeCFile_StoredBlobPrefixedWirePreservedVerbatim is the regression // test for issue #112: when a cfile card's wire payload already carries // Fossil's on-disk blob format ([4-byte BE size][zlib data]) -- exactly // what send_compressed_file transmits, since it reads a blob.content column // straight off disk -- StoredBlob must be those exact bytes, not a // recompression of the decompressed content. This is the case that fires // against a real Fossil server. func TestDecodeCFile_StoredBlobPrefixedWirePreservedVerbatim(t *testing.T) { plain := []byte("some artifact content, long enough that zlib actually does something") var zbuf bytes.Buffer zw := zlib.NewWriter(&zbuf) if _, err := zw.Write(plain); err != nil { t.Fatalf("zlib write: %v", err) } if err := zw.Close(); err != nil { t.Fatalf("zlib close: %v", err) } // Fossil's on-disk blob format: 4-byte BE uncompressed-size prefix, // then the zlib stream, exactly as send_compressed_file would send it. wireBlob := make([]byte, 0, 4+zbuf.Len()) sizeHdr := make([]byte, 4) binary.BigEndian.PutUint32(sizeHdr, uint32(len(plain))) wireBlob = append(wireBlob, sizeHdr...) wireBlob = append(wireBlob, zbuf.Bytes()...) wire := fmt.Sprintf("cfile aabbcc %d %d\n", len(plain), len(wireBlob)) r := bufio.NewReader(strings.NewReader(wire + string(wireBlob))) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } c, ok := card.(*CFileCard) if !ok { t.Fatalf("expected *CFileCard, got %T", card) } if !bytes.Equal(c.Content, plain) { t.Fatalf("Content = %q, want %q", c.Content, plain) } if !bytes.Equal(c.StoredBlob, wireBlob) { t.Fatalf("StoredBlob was re-encoded instead of preserved verbatim:\n got %x\n want %x", c.StoredBlob, wireBlob) } } // TestDecodeCFile_StoredBlobPlainZlibGetsPrefixWithoutRecompression covers // the other wire form: our own encoder sends plain zlib with no size // prefix. StoredBlob must still be built without a second zlib pass -- // only the 4-byte length header is bookkeeping added on top of the exact // zlib bytes that were received. func TestDecodeCFile_StoredBlobPlainZlibGetsPrefixWithoutRecompression(t *testing.T) { plain := []byte("other artifact content, also long enough for zlib to matter here") c := &CFileCard{UUID: "abc123", Content: plain} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatalf("EncodeCard: %v", err) } r := bufio.NewReader(&buf) got, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } gotCFile, ok := got.(*CFileCard) if !ok { t.Fatalf("expected *CFileCard, got %T", got) } if len(gotCFile.StoredBlob) < 4 { t.Fatalf("StoredBlob too short to carry a size prefix: %d bytes", len(gotCFile.StoredBlob)) } gotUsize := binary.BigEndian.Uint32(gotCFile.StoredBlob[:4]) if int(gotUsize) != len(plain) { t.Fatalf("StoredBlob size prefix = %d, want %d", gotUsize, len(plain)) } // Decompressing StoredBlob (skipping the prefix we just checked) must // reproduce plain exactly. zr, err := zlib.NewReader(bytes.NewReader(gotCFile.StoredBlob[4:])) if err != nil { t.Fatalf("zlib.NewReader(StoredBlob[4:]): %v", err) } defer zr.Close() out, err := io.ReadAll(zr) if err != nil { t.Fatalf("zlib read: %v", err) } if !bytes.Equal(out, plain) { t.Fatalf("StoredBlob decompressed = %q, want %q", out, plain) } } func TestEncode_CFileCompression(t *testing.T) { // Verify that compression actually reduces size for compressible data content := bytes.Repeat([]byte("AAAA"), 1000) c := &CFileCard{UUID: "abc123", Content: content} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wireLen := buf.Len() // The wire format includes the header line + compressed data. // For 4000 bytes of "AAAA", compressed should be much smaller. if wireLen >= len(content) { t.Errorf("compressed wire length %d should be less than content length %d", wireLen, len(content)) } } func TestDecode_CFileUSizeMismatch(t *testing.T) { // USIZE is metadata, not a frame boundary: fossil's // xfer_accept_compressed_file never checks it against the inflated size, // so a USIZE that disagrees is carried through, not rejected (§7.2). The // content is bound by its hash at storage time instead. content := []byte("hello") var zbuf bytes.Buffer zw := zlib.NewWriter(&zbuf) zw.Write(content) zw.Close() // Claim usize is 999 but actual decompressed size is 5. wire := fmt.Sprintf("cfile abc123 999 %d\n", zbuf.Len()) input := append([]byte(wire), zbuf.Bytes()...) r := bufio.NewReader(bytes.NewReader(input)) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } c, ok := card.(*CFileCard) if !ok { t.Fatalf("got %T, want *CFileCard", card) } if string(c.Content) != "hello" { t.Errorf("Content = %q, want %q", c.Content, "hello") } if c.USize != 999 { t.Errorf("USize = %d, want 999 (carried through verbatim)", c.USize) } } // --- Task 6: ConfigCard --- func TestRoundTrip_Config(t *testing.T) { c := &ConfigCard{Name: "css", Content: []byte("body { color: red; }")} got := roundTrip(t, c).(*ConfigCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } if !bytes.Equal(got.Content, c.Content) { t.Errorf("Content = %q, want %q", got.Content, c.Content) } } func TestEncode_WireFormat_ConfigTrailingNewline(t *testing.T) { c := &ConfigCard{Name: "css", Content: []byte("data")} var buf bytes.Buffer if err := EncodeCard(&buf, c); err != nil { t.Fatal(err) } wire := buf.Bytes() // Should be: "config css 4\ndata\n" — WITH trailing \n expected := []byte("config css 4\ndata\n") if !bytes.Equal(wire, expected) { t.Errorf("wire = %q, want %q", wire, expected) } // Verify trailing newline IS present if wire[len(wire)-1] != '\n' { t.Error("config card wire should end with \\n") } } // --- Task 7: UVFileCard --- func TestRoundTrip_UVFile(t *testing.T) { c := &UVFileCard{ Name: "data/config.json", MTime: 1700000000, Hash: "1f09d30c707d53f3d16c530dd73d70a6ce7596a9", // SHA1("hello, world!") Size: 13, Flags: 0, Content: []byte("hello, world!"), } got := roundTrip(t, c).(*UVFileCard) if got.Name != c.Name { t.Errorf("Name = %q, want %q", got.Name, c.Name) } if got.MTime != c.MTime { t.Errorf("MTime = %d, want %d", got.MTime, c.MTime) } if got.Hash != c.Hash { t.Errorf("Hash = %q, want %q", got.Hash, c.Hash) } if got.Size != c.Size { t.Errorf("Size = %d, want %d", got.Size, c.Size) } if got.Flags != c.Flags { t.Errorf("Flags = %d, want %d", got.Flags, c.Flags) } if !bytes.Equal(got.Content, c.Content) { t.Errorf("Content = %q, want %q", got.Content, c.Content) } } func TestRoundTrip_UVFileDeleted(t *testing.T) { c := &UVFileCard{ Name: "old-file.txt", MTime: 1700000000, Hash: "-", Size: 0, Flags: 0x0001, // deleted } got := roundTrip(t, c).(*UVFileCard) if got.Flags != 0x0001 { t.Errorf("Flags = %d, want 1", got.Flags) } if got.Hash != "-" { t.Errorf("Hash = %q, want %q", got.Hash, "-") } if len(got.Content) != 0 { t.Errorf("Content length = %d, want 0 for deleted file", len(got.Content)) } } // TestDecode_UVFileMalformedNameOrHash pins fossil's uvfile line checks: a // HASH that is neither an artifact name nor "-", or a NAME that is not a // strict simple pathname, makes the line malformed, so nothing is applied. func TestDecode_UVFileMalformedNameOrHash(t *testing.T) { for _, line := range []string{ "uvfile n 100 garbage 0 4\n", "uvfile /abs 100 - 0 1\n", "uvfile a/../b 100 - 0 1\n", "uvfile a\\b 100 - 0 1\n", "uvfile \xff 100 - 0 1\n", } { msg, err := DecodeUncompressed([]byte(line)) if err != nil { t.Fatalf("%q: %v", line, err) } if _, ok := msg.Cards[0].(*BadFileCard); !ok { t.Errorf("%q decoded as %T, want a malformed uvfile line", line, msg.Cards[0]) } } } func TestRoundTrip_UVFileContentOmitted(t *testing.T) { c := &UVFileCard{ Name: "large-file.bin", MTime: 1700000000, Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", Size: 0, Flags: 0x0004, // content omitted } got := roundTrip(t, c).(*UVFileCard) if got.Flags != 0x0004 { t.Errorf("Flags = %d, want 4", got.Flags) } if len(got.Content) != 0 { t.Errorf("Content length = %d, want 0 for content-omitted file", len(got.Content)) } } // Decode a line without trailing newline (EOF-terminated) func TestDecode_NoTrailingNewline(t *testing.T) { r := bufio.NewReader(strings.NewReader("gimme abc123")) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } g, ok := card.(*GimmeCard) if !ok { t.Fatalf("got %T, want *GimmeCard", card) } if g.UUID != "abc123" { t.Errorf("UUID = %q, want %q", g.UUID, "abc123") } } // TestDecodeCFile_FossilBlobFormat verifies that cfile cards using Fossil's // native blob compression format (4-byte BE size prefix + zlib) are decoded // correctly. This is what Fossil's send_compressed_file() sends during clone v3, // reading raw blob content directly from the blob table (xfer.c:657-683). func TestDecodeCFile_FossilBlobFormat(t *testing.T) { content := []byte("hello from fossil blob format") uuid := "fossilblob123" // Create Fossil blob-format payload: [4-byte BE uncompressed size][zlib data]. var compressed bytes.Buffer var sizePrefix [4]byte binary.BigEndian.PutUint32(sizePrefix[:], uint32(len(content))) compressed.Write(sizePrefix[:]) zw := zlib.NewWriter(&compressed) zw.Write(content) zw.Close() // Build raw cfile wire: "cfile UUID USIZE CSIZE\nPAYLOAD" csize := compressed.Len() line := fmt.Sprintf("cfile %s %d %d\n", uuid, len(content), csize) var wire bytes.Buffer wire.WriteString(line) wire.Write(compressed.Bytes()) wire.WriteByte('\n') r := bufio.NewReader(&wire) card, err := DecodeCard(r) if err != nil { t.Fatalf("DecodeCard: %v", err) } cf, ok := card.(*CFileCard) if !ok { t.Fatalf("expected *CFileCard, got %T", card) } if cf.UUID != uuid { t.Errorf("UUID = %q, want %q", cf.UUID, uuid) } if !bytes.Equal(cf.Content, content) { t.Errorf("Content = %q, want %q", cf.Content, content) } } // --- ci-lock pragma round-trip --- func TestCkinLockPragma_RoundTrip(t *testing.T) { cards := []Card{ &PragmaCard{Name: "ci-lock", Values: []string{"abc123def456", "client-001"}}, &PragmaCard{Name: "ci-lock-fail", Values: []string{"alice", "1712000000"}}, } msg := &Message{Cards: cards} encoded, err := msg.Encode() if err != nil { t.Fatalf("Encode: %v", err) } decoded, err := Decode(encoded, ContentTypeCompressed) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded.Cards) != 2 { t.Fatalf("got %d cards, want 2", len(decoded.Cards)) } lock := decoded.Cards[0].(*PragmaCard) if lock.Name != "ci-lock" || len(lock.Values) != 2 || lock.Values[0] != "abc123def456" || lock.Values[1] != "client-001" { t.Fatalf("ci-lock = %+v", lock) } fail := decoded.Cards[1].(*PragmaCard) if fail.Name != "ci-lock-fail" || len(fail.Values) != 2 || fail.Values[0] != "alice" || fail.Values[1] != "1712000000" { t.Fatalf("ci-lock-fail = %+v", fail) } } // TestCloneCardWireRoundTrip pins that every clone wire form re-encodes to // the bytes it was decoded from. The bare `clone` and `clone 0 0` are // distinct on the wire but both parse to Version 0 / SeqNo 0, so only // SeqNoIsDecimal separates them — consuming that field as mere presence // would collapse `clone 0 0` back into a bare `clone`. func TestCloneCardWireRoundTrip(t *testing.T) { for _, line := range []string{ "clone\n", "clone 0 0\n", "clone 3 0\n", "clone 3 1\n", "clone 3 4096\n", "clone 3 -1\n", "clone 1 7\n", } { card, err := DecodeCard(bufio.NewReader(strings.NewReader(line))) if err != nil { t.Fatalf("decode %q: %v", line, err) } var buf bytes.Buffer if err := EncodeCard(&buf, card); err != nil { t.Fatalf("encode %q: %v", line, err) } if buf.String() != line { t.Errorf("round trip %q -> %q", line, buf.String()) } } }