Artifact 5f16e0946981cacc46e24970126ecb6e64ac4f76b8fac22fadd6a9c603be53f4:
- File
internal/xfer/codec_test.go
— part of check-in
[6c69fc1231]
at
2026-10-12 23:20:45
on branch main
— fix(sync): send clone replies as fossil's page_xfer does (#306) (#360)
fossil's send_compressed_file sends a cfile as the blob table stores it and pads a payload that does not end in LF; page_xfer sends a version 3 clone's reply as application/x-fossil-uncompressed, sends the unversioned catalog with a clone when uv-sync is on, and refuses a clone with a reset, the push card and "error not authorized to clone". The server recompressed each cfile, never padded, compressed the reply, sent no catalog, appended its refusal to the reply, and restarted a clone whose SEQNO was negative.
CFileCard's StoredBlob, set by the clone path from the blob table, is now what the encoder sends; xfer.Message.Uncompressed tells the HTTP server to send a reply as card text. The catalog goes to a requester with Read, as for uv-hash: fossil lets Clone imply Read there, which this server does not. A negative SEQNO, which section 8.1 exempts from its fatal, now ends the clone with clone_seqno 0, and the unit test that expected a restart expects that. (user: [email protected], size: 43747) [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: "abc123def456", 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)) } } func TestRoundTrip_UVFileContentOmitted(t *testing.T) { c := &UVFileCard{ Name: "large-file.bin", MTime: 1700000000, Hash: "abc123", 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()) } } }