Artifact c9a5693b26b4b1e964e8e2e45128746dbaa9883a1cfbdf21ccecf4a5c1b9c95d:
- File
internal/deck/cardcodec_test.go
— part of check-in
[7b8f655da6]
at
2026-10-12 17:52:27
on branch main
— fix(deck): enforce card legality, required cards and per-type semantics (#273) (#339)
deck.Parse determined an artifact's type but then checked nothing against it, so a card a type disallows, a required card left out, or a tag that breaks a per-type semantic rule all parsed.
Add deck/legality.go with fossil's manifestCardTypes table (allowed and required cards per type) and the §5.4 semantic checks: a control artifact's tags may not target itself, a technote's tags are all self-referential singletons, and a forum post carries at most one of an H or I card and at most one parent. Parse now determines the type (inferType gains the B, J and Q type-setters it was missing and falls back to a control artifact, not an empty check-in, when nothing sets a type and there is no comment or parent), then applies legality and the semantic checks. A missing Z card is now caught by the universal required-card rule.
Several internal/manifest and internal/deck tests built incomplete artifacts fossil also rejects -- a cluster with a D card, a forum post with both an H and an I card, control-shaped D/U/Z artifacts with no T card, and single-card fixtures missing their type's required cards. Frame each as a valid artifact of its type; the behavior each test pins is unchanged.
This also makes two repostate conformance cases conform: the technote with a non-self tag is now rejected at parse (not by a later isArtifact gate), and the opaque-control rebuild case. Their marks are removed in conformance/repostate_gaps_test.go. (user: [email protected], size: 8570) [annotate] [blame] [check-ins using]
package deck import ( "crypto/md5" "fmt" "strings" "testing" "time" ) // 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) { // A valid ticket: §5.2 requires D, J, K, U and Z. wire := withZCard("D 2024-01-15T10:30:00.000\nJ comment a\\svalue\\swith\\sspaces\n" + "K " + strings.Repeat("a", 40) + "\nU u\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) { // A branch tag targets its own check-in ("*"), so the artifact is a // check-in (which §5.4 allows self-tags on), not a control artifact. d := &Deck{ C: "c", D: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), T: []TagCard{{Type: TagPropagating, Name: "sym-my branch", UUID: "*"}}, U: User("u"), } 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) { // The branch property targets the check-in itself ("*"), so this is a // check-in: a comment and a user make it a well-formed one. wire := withZCard("C c\nD 2024-01-15T10:30:00.000\nT *branch * my\\sbranch\nU u\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) { // A self-targeting tag ("*") makes this a check-in. d := &Deck{ C: "c", D: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), T: []TagCard{{Type: TagPropagating, Name: "sym-x", UUID: "*"}}, U: User("u"), } 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) { // Both are framed as check-ins (a comment and a user): a check-in's T card // may target another artifact by hash or target itself with "*" (§5.4 // restricts self-tags only on control artifacts and technotes). target := strings.Repeat("b", 40) wire := withZCard("C c\nD 2024-01-15T10:30:00.000\nT +sym-x " + target + "\nU u\n") if _, err := Parse(wire); err != nil { t.Fatalf("Parse rejected a valid 40-hex target: %v", err) } wireStar := withZCard("C c\nD 2024-01-15T10:30:00.000\nT +sym-x *\nU u\n") if _, err := Parse(wireStar); err != nil { t.Fatalf("Parse rejected the literal '*' target: %v", err) } }