Artifact b7a7c871076e88899606e342fb60fb01a8d4760e3132485a5b431345dfaaa272:
- File
internal/deck/cardcodec_test.go
— part of check-in
[1af61c2713]
at
2026-10-12 01:42:44
on branch main
— fix(deck): tokenize cards as fossil does and enforce per-card arity (#266, #267) (#331)
The parser split card arguments with strings.Fields and strings.TrimSpace, which collapsed a repeated space, split on tab and trimmed a trailing CR, and its line reader skipped blank lines. It also gave no card a maximum arity, so an extra token was absorbed.
Replace the line reader with a scanner that mirrors fossil's next_token/next_card: tokens are separated by a single space, a card ends at LF, and tab and CR are ordinary token bytes. A blank line, an empty token between two others, a bare card where a value is required, and residue after the last card are rejected; a line ending in a space keeps a present-but-empty last value. The W card's size is parsed as leading decimal digits with an overflow guard and the body must be newline terminated. The clearsign signature trailer is matched on 29 bytes, as fossil's remove_pgp_signature does, so a mangled final dash no longer hides it.
A faithful tokenizer enforces arity intrinsically -- fossil rejects an extra token as residue once the card's fields are read -- so #267 is resolved by the same change: each card is held to its exact Table 2 (§4.2.1) token count. The card parsers now take the scanner's tokens rather than one argument string; update the J-card and U-card unit tests to the new shape and to fossil's actual verdict on a multi-space U-card (rejected, not "anonymous"). (user: [email protected], size: 7722) [annotate] [blame] [check-ins using]
package deck import ( "crypto/md5" "fmt" "strings" "testing" ) // withZCard appends the §4.7.19 Z checksum card to a manifest body so Parse // accepts it. The cards in body must already appear in ascending-letter // order, which Parse enforces. func withZCard(body string) []byte { sum := md5.Sum([]byte(body)) return []byte(fmt.Sprintf("%sZ %x\n", body, sum)) } // --- #90: J-card decode direction (§4.7.8) --- // TestParseJCardDecodesValueKeepsNameVerbatim pins §4.7.8: the J-card value // is escape-decoded while the field name is stored verbatim, matching // canonical manifest.c which defossilizes only zValue and compares the raw // zName. A value carrying escaped spaces must arrive decoded; a name // carrying an escape must arrive unchanged. func TestParseJCardDecodesValueKeepsNameVerbatim(t *testing.T) { d := &Deck{} // parseJCard now receives the card's tokens, split on single spaces by the // scanner, rather than one raw argument string. if err := parseJCard(d, []string{`a\sname`, `a\svalue`}); err != nil { t.Fatalf("parseJCard: %v", err) } if len(d.J) != 1 { t.Fatalf("J count = %d, want 1", len(d.J)) } if got := d.J[0].Name; got != `a\sname` { t.Errorf("Name = %q, want verbatim %q", got, `a\sname`) } if got := d.J[0].Value; got != "a value" { t.Errorf("Value = %q, want decoded %q", got, "a value") } } // TestMarshalJCardEncodesValueKeepsNameVerbatim is the write-side mirror of // the decode direction: the value is escape-encoded on the way out and the // name is written verbatim, so a decoded value with spaces reproduces its // wire escapes. func TestMarshalJCardEncodesValueKeepsNameVerbatim(t *testing.T) { var b strings.Builder d := &Deck{J: []TicketField{{Name: "comment", Value: "a value with spaces"}}} marshalCards(&b, d) if !strings.Contains(b.String(), "J comment a\\svalue\\swith\\sspaces\n") { t.Fatalf("J value not escape-encoded on marshal:\n%s", b.String()) } } // TestJCardWireRoundTrip pins that Marshal reproduces the original wire // bytes for an unmodified J-card whose value contains spaces (§4.7.8). func TestJCardWireRoundTrip(t *testing.T) { wire := withZCard("D 2024-01-15T10:30:00.000\nJ comment a\\svalue\\swith\\sspaces\n") d, err := Parse(wire) if err != nil { t.Fatalf("Parse: %v", err) } if got := d.J[0].Value; got != "a value with spaces" { t.Fatalf("Value = %q, want decoded", got) } out, _ := d.Marshal() if !strings.Contains(string(out), "J comment a\\svalue\\swith\\sspaces\n") { t.Fatalf("J line not reproduced on round-trip:\n%s", out) } } // --- #91: T-card marshal escape-encodes the tag name (§4.7.16) --- // TestTCardNameWithSpaceRoundTrips pins that a tag name containing a space // round-trips Marshal -> Parse unchanged. Marshal must escape-encode the // name so "sym-my branch" reaches the wire as "sym-my\sbranch" and parses // back to a single name token, not two. func TestTCardNameWithSpaceRoundTrips(t *testing.T) { d := &Deck{ T: []TagCard{{Type: TagPropagating, Name: "sym-my branch", UUID: "*"}}, } out, _ := d.Marshal() if !strings.Contains(string(out), "T *sym-my\\sbranch *\n") { t.Fatalf("tag name not escape-encoded on marshal:\n%s", out) } parsed, err := Parse(out) if err != nil { t.Fatalf("Parse: %v", err) } if len(parsed.T) != 1 { t.Fatalf("T count = %d, want 1", len(parsed.T)) } if got := parsed.T[0].Name; got != "sym-my branch" { t.Errorf("Name = %q, want %q", got, "sym-my branch") } if got := parsed.T[0].UUID; got != "*" { t.Errorf("UUID = %q, want %q", got, "*") } } // TestTCardTargetHashStaysUnencoded pins §4.7.16: the target token is never // decoded on the way in, so it must not be encoded on the way out. func TestTCardTargetHashStaysUnencoded(t *testing.T) { target := strings.Repeat("a", 40) var b strings.Builder d := &Deck{T: []TagCard{{Type: TagSingleton, Name: "sym-x", UUID: target}}} marshalCards(&b, d) if !strings.Contains(b.String(), "T +sym-x "+target+"\n") { t.Fatalf("target hash was altered on marshal:\n%s", b.String()) } } // TestTCardValueWithSpaceRoundTrips pins §4.7.16 for the third T-card token: // the value is escape-decoded on parse and escape-encoded on marshal, the // same rule the name follows. The wire bytes asserted here are the bytes // real fossil writes for `fossil branch new 'my branch'`: // // T *branch * my\sbranch // T *sym-my\sbranch * // // Leaving the value raw made an imported branch literally named // "my\sbranch" all the way through crosslink. func TestTCardValueWithSpaceRoundTrips(t *testing.T) { wire := withZCard("D 2024-01-15T10:30:00.000\nT *branch * my\\sbranch\n") d, err := Parse(wire) if err != nil { t.Fatalf("Parse: %v", err) } if got := d.T[0].Value; got != "my branch" { t.Fatalf("Value = %q, want decoded %q", got, "my branch") } out, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } if !strings.Contains(string(out), "T *branch * my\\sbranch\n") { t.Fatalf("T value not escape-encoded on marshal:\n%s", out) } } // TestTCardEmptyValueOmitted guards the optional-value case: a T-card with no // value token must not gain one, and decoding must not turn an absent value // into a present-but-empty one. func TestTCardEmptyValueOmitted(t *testing.T) { d := &Deck{T: []TagCard{{Type: TagPropagating, Name: "sym-x", UUID: "*"}}} out, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } if !strings.Contains(string(out), "T *sym-x *\n") { t.Fatalf("valueless T-card not reproduced:\n%s", out) } parsed, err := Parse(out) if err != nil { t.Fatalf("Parse: %v", err) } if got := parsed.T[0].Value; got != "" { t.Errorf("Value = %q, want empty", got) } } // --- #93: parseTCard §4.7.16 validation --- // TestParseTCardRejectsHexOnlyName pins that a T-card name which after the // sign character is entirely hex digits is rejected, because it cannot be // told apart from a hash target (§4.7.16). func TestParseTCardRejectsHexOnlyName(t *testing.T) { hexName := strings.Repeat("a", 40) wire := withZCard("D 2024-01-15T10:30:00.000\nT +" + hexName + " *\n") if _, err := Parse(wire); err == nil { t.Fatalf("Parse accepted a hex-only tag name %q, want error", hexName) } } // TestMarshalTCardRejectsHexOnlyName is the write-side mirror: Marshal must // refuse to emit a tag name the parser rejects, otherwise a caller can store // an artifact this repository can never read back. Canonical fossil never // produces one either -- it prefixes branch and user tags with "sym-". func TestMarshalTCardRejectsHexOnlyName(t *testing.T) { d := &Deck{T: []TagCard{{Type: TagSingleton, Name: "cafe", UUID: "*"}}} if _, err := d.Marshal(); err == nil { t.Fatalf("Marshal accepted a hex-only tag name, want error") } } // TestParseTCardRejectsInvalidTarget pins that a target which is neither a // valid artifact hash nor the literal '*' is rejected (§4.7.16). func TestParseTCardRejectsInvalidTarget(t *testing.T) { wire := withZCard("D 2024-01-15T10:30:00.000\nT +sym-x notahash\n") if _, err := Parse(wire); err == nil { t.Fatalf("Parse accepted an invalid T-card target, want error") } } // TestParseTCardAcceptsValidTargets guards against over-rejection: a real // 40-hex target and the literal '*' must both still parse (§4.7.16). func TestParseTCardAcceptsValidTargets(t *testing.T) { target := strings.Repeat("b", 40) wire := withZCard("D 2024-01-15T10:30:00.000\nT +sym-x " + target + "\n") if _, err := Parse(wire); err != nil { t.Fatalf("Parse rejected a valid 40-hex target: %v", err) } wireStar := withZCard("D 2024-01-15T10:30:00.000\nT +sym-x *\n") if _, err := Parse(wireStar); err != nil { t.Fatalf("Parse rejected the literal '*' target: %v", err) } }