Artifact 125b644e798f230f8def1247d99b671d76f7660f85e2d6ef8f92d435ce19bad8:
- File
internal/deck/occurrence_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: 18722) [annotate] [blame] [check-ins using]
package deck import ( "crypto/md5" "fmt" "strings" "testing" "time" ) // hashN builds a distinct 40-character lowercase artifact hash whose first // byte is c, so tests can order hashes by inspection. func hashN(c byte) string { return string(c) + strings.Repeat("0", 39) } // parseBody frames body as a complete artifact by appending the trailing Z // card VerifyZ requires, then parses it. Every test in this file works on // well-framed input so that any error returned is the occurrence or // ordering rule under test, never checksum framing. func parseBody(body string) (*Deck, error) { h := md5.Sum([]byte(body)) return Parse([]byte(fmt.Sprintf("%sZ %x\n", body, h))) } // tsFixture is a well-formed D/E timestamp the framing helpers reuse. const tsFixture = "2024-01-15T10:30:00.000" // The helpers below frame a run of cards of one type as a complete, valid // artifact of that type, so that a parse rejection a test observes is the // occurrence or ordering rule under test rather than §5.2's required-card // rule. The run's own cards sort into place because D sorts before the // repeatable manifest/control cards and U/K/W after them. // checkinCards frames manifest-family cards (F, P, Q, ...) as a valid // check-in: a D card makes it a manifest, which §5.2 requires only D and Z of. func checkinCards(cards string) string { return "D " + tsFixture + "\n" + cards } // controlCards frames T cards (each targeting another artifact) as a valid // control artifact: D, the tags, then U. func controlCards(tcards string) string { return "D " + tsFixture + "\n" + tcards + "U cu\n" } // ticketCards frames J cards as a valid ticket: D, the fields, K, then U. func ticketCards(jcards string) string { return "D " + tsFixture + "\n" + jcards + "K " + hashN('e') + "\nU tu\n" } // TestSingleOccurrenceCardsRejectDuplicates covers every one of the 14 // single-occurrence types of draft-fossil-artifact-format-00 §4.5.1. Each // case feeds two syntactically valid cards of one type; the card-type // ordering rule of §4.4 permits equal consecutive letters, so nothing but // the duplicate guard can reject these. func TestSingleOccurrenceCardsRejectDuplicates(t *testing.T) { ts := "D " + tsFixture + "\n" cases := []struct { card byte // dup holds two cards of the type; the duplicate guard rejects it in // the card loop, before the required-card rule runs, so dup need not be // a complete artifact. single is a complete, valid artifact of the // card's type carrying exactly one such card, so it must parse. dup, single string }{ {'A', "A one.txt " + hashN('a') + "\nA two.txt " + hashN('b') + "\n", "A one.txt " + hashN('a') + "\n" + ts}, {'B', "B " + hashN('a') + "\nB " + hashN('b') + "\n", "B " + hashN('a') + "\n" + ts}, {'C', "C first\nC second\n", "C first\n" + ts}, {'D', "D 2024-01-15T10:30:00.000\nD 2024-01-16T10:30:00.000\n", "C c\n" + ts}, {'E', "E 2024-01-15T10:30:00 " + hashN('a') + "\nE 2024-01-16T10:30:00 " + hashN('b') + "\n", ts + "E 2024-01-15T10:30:00 " + hashN('a') + "\nW 1\nx\n"}, {'G', "G " + hashN('a') + "\nG " + hashN('b') + "\n", ts + "G " + hashN('a') + "\nU u\nW 1\nx\n"}, {'H', "H first\nH second\n", ts + "H first\nU u\nW 1\nx\n"}, {'I', "I " + hashN('a') + "\nI " + hashN('b') + "\n", ts + "I " + hashN('a') + "\nU u\nW 1\nx\n"}, {'K', "K " + hashN('a') + "\nK " + hashN('b') + "\n", ts + "J f v\nK " + hashN('a') + "\nU u\n"}, {'L', "L PageOne\nL PageTwo\n", ts + "L PageOne\nU u\nW 1\nx\n"}, {'N', "N text/plain\nN text/x-markdown\n", "C c\n" + ts + "N text/plain\n"}, {'R', "R " + strings.Repeat("0", 32) + "\nR " + strings.Repeat("1", 32) + "\n", ts + "R " + strings.Repeat("0", 32) + "\n"}, {'U', "U alice\nU bob\n", "C c\n" + ts + "U alice\n"}, {'W', "W 3\nabc\nW 3\nxyz\n", ts + "L P\nU u\nW 3\nabc\n"}, } if len(cases) != 14 { t.Fatalf("case count = %d, want 14 single-occurrence types", len(cases)) } for _, tc := range cases { if _, err := parseBody(tc.dup); err == nil { t.Errorf("duplicate %c card: Parse succeeded, want error", tc.card) } // The single occurrence must still parse, so the guard cannot be a // blanket rejection of the letter. if _, err := parseBody(tc.single); err != nil { t.Errorf("single %c card: Parse: %v", tc.card, err) } } } // TestDuplicateCardIsErrorNotPanic pins that malformed peer input is // reported through the error return. The assertion idiom in this package // panics only on programmer error; artifact bytes arrive from the network. func TestDuplicateCardIsErrorNotPanic(t *testing.T) { defer func() { if r := recover(); r != nil { t.Fatalf("Parse panicked on duplicate card: %v", r) } }() if _, err := parseBody("U alice\nU bob\n"); err == nil { t.Fatal("duplicate U card: Parse succeeded, want error") } } // TestFCardOrdering exercises the strict-ascending F rule of §4.5.2. func TestFCardOrdering(t *testing.T) { ascending := "F a.txt " + hashN('a') + "\nF b.txt " + hashN('b') + "\n" if _, err := parseBody(checkinCards(ascending)); err != nil { t.Fatalf("ascending F run: Parse: %v", err) } descending := "F b.txt " + hashN('a') + "\nF a.txt " + hashN('b') + "\n" if _, err := parseBody(checkinCards(descending)); err == nil { t.Error("descending F run: Parse succeeded, want error") } equal := "F a.txt " + hashN('a') + "\nF a.txt " + hashN('b') + "\n" if _, err := parseBody(checkinCards(equal)); err == nil { t.Error("duplicate F name: Parse succeeded, want error") } } // TestFCardOrderingUsesDecodedFilename pins that the F sort check runs on // the post-decode filename, not the raw wire token (§4.5.2). The two names // "a b" and "a-b" order oppositely in the two forms: decoded, 0x20 sorts // before 0x2D; encoded, "a\sb" begins its third byte with 0x5C, which // sorts after 0x2D. A raw-token comparison would therefore accept the // descending run and reject the ascending one. func TestFCardOrderingUsesDecodedFilename(t *testing.T) { decodedAscending := `F a\sb ` + hashN('a') + "\nF a-b " + hashN('b') + "\n" d, err := parseBody(checkinCards(decodedAscending)) if err != nil { t.Fatalf("decoded-ascending F run: Parse: %v", err) } if len(d.F) != 2 || d.F[0].Name != "a b" || d.F[1].Name != "a-b" { t.Fatalf("F = %+v, want decoded [a b, a-b]", d.F) } decodedDescending := "F a-b " + hashN('a') + "\nF a\\sb " + hashN('b') + "\n" if _, err := parseBody(checkinCards(decodedDescending)); err == nil { t.Error("decoded-descending F run: Parse succeeded, want error") } } // TestJCardOrdering exercises the strict-ascending J rule of §4.5.2. func TestJCardOrdering(t *testing.T) { if _, err := parseBody(ticketCards("J alpha 1\nJ beta 2\n")); err != nil { t.Fatalf("ascending J run: Parse: %v", err) } if _, err := parseBody(ticketCards("J beta 1\nJ alpha 2\n")); err == nil { t.Error("descending J run: Parse succeeded, want error") } if _, err := parseBody(ticketCards("J alpha 1\nJ alpha 2\n")); err == nil { t.Error("duplicate J field name: Parse succeeded, want error") } } // TestMCardOrdering exercises the strict-ascending M rule of §4.5.2. func TestMCardOrdering(t *testing.T) { if _, err := parseBody("M " + hashN('a') + "\nM " + hashN('b') + "\n"); err != nil { t.Fatalf("ascending M run: Parse: %v", err) } if _, err := parseBody("M " + hashN('b') + "\nM " + hashN('a') + "\n"); err == nil { t.Error("descending M run: Parse succeeded, want error") } if _, err := parseBody("M " + hashN('a') + "\nM " + hashN('a') + "\n"); err == nil { t.Error("duplicate M hash: Parse succeeded, want error") } } // TestTCardTwoLevelOrdering exercises the two-level (name, target hash) // rule of §4.5.2. The primary key is the full name token including its // leading sign character (§4.7.16), so "+x" and "*x" are distinct names. // // The prefix-name case is what distinguishes a two-level comparison from // a comparison of the concatenated name+target: under concatenation the // target's leading hex digit competes with the longer name's next // character, so "f0..." on "+sym-foo" would sort it after "+sym-foobar" // and the run would be rejected. Two-level accepts it. func TestTCardTwoLevelOrdering(t *testing.T) { if _, err := parseBody(controlCards("T +sym-foo f" + strings.Repeat("0", 39) + "\nT +sym-foobar " + strings.Repeat("0", 40) + "\n")); err != nil { t.Fatalf("prefix T names with a high target hash: Parse: %v", err) } if _, err := parseBody(controlCards("T +alpha " + hashN('a') + "\nT +beta " + hashN('a') + "\n")); err != nil { t.Fatalf("ascending T names: Parse: %v", err) } if _, err := parseBody(controlCards("T +beta " + hashN('a') + "\nT +alpha " + hashN('b') + "\n")); err == nil { t.Error("descending T names: Parse succeeded, want error") } // Equal names: the target hash must strictly increase. if _, err := parseBody(controlCards("T +alpha " + hashN('a') + "\nT +alpha " + hashN('b') + "\n")); err != nil { t.Fatalf("equal T names with ascending hash: Parse: %v", err) } if _, err := parseBody(controlCards("T +alpha " + hashN('b') + "\nT +alpha " + hashN('a') + "\n")); err == nil { t.Error("equal T names with descending hash: Parse succeeded, want error") } if _, err := parseBody(controlCards("T +alpha " + hashN('a') + "\nT +alpha " + hashN('a') + "\n")); err == nil { t.Error("equal T names with equal hash: Parse succeeded, want error") } } // TestTCardOrderingUsesDecodedName pins that the T ordering key is the // escape-decoded name (§4.7.16), the same post-decode rule §4.5.2 states // for F. The two tag names below order oppositely in the two forms: // decoded, 0x20 sorts before 0x2D; on the wire, "\s" begins with 0x5C, // which sorts after 0x2D. Reachable in practice, because a branch name // may contain a space and so reaches the T card as "sym-my\sbranch". func TestTCardOrderingUsesDecodedName(t *testing.T) { decodedAscending := `T *sym-a\sb ` + hashN('a') + "\nT *sym-a-c " + hashN('b') + "\n" if _, err := parseBody(controlCards(decodedAscending)); err != nil { t.Fatalf("decoded-ascending T run: Parse: %v", err) } decodedDescending := "T *sym-a-c " + hashN('a') + "\nT *sym-a\\sb " + hashN('b') + "\n" if _, err := parseBody(controlCards(decodedDescending)); err == nil { t.Error("decoded-descending T run: Parse succeeded, want error") } } // TestTCardRejectsUnknownSign pins the sign-prefix check of §4.7.16: the // first character of the name token MUST be +, - or *. Without it a sign // byte >= 0x80 would expand to two bytes when the ordering key is // assembled, silently corrupting the comparison. func TestTCardRejectsUnknownSign(t *testing.T) { if _, err := parseBody(controlCards("T ?alpha " + hashN('a') + "\n")); err == nil { t.Error("T card with sign '?': Parse succeeded, want error") } if _, err := parseBody(controlCards("T \xc3alpha " + hashN('a') + "\n")); err == nil { t.Error("T card with a non-ASCII sign byte: Parse succeeded, want error") } for _, sign := range []string{"+", "-", "*"} { if _, err := parseBody(controlCards("T " + sign + "alpha " + hashN('a') + "\n")); err != nil { t.Errorf("T card with sign %q: Parse: %v", sign, err) } } } // TestZCardTokenValidation pins §4.7.19: the Z payload is a checksum token // of exactly 32 hexadecimal characters, never decoded. An early Z line is // legal content (§4.5.1) but it is still a typed card, not an // arbitrary-byte channel. func TestZCardTokenValidation(t *testing.T) { // A valid control artifact (D, a tag, U) so the only thing under test is // the early Z line that follows it; the framing Z is appended by parseBody. prefix := controlCards("T +tag " + hashN('a') + "\n") bad := []struct { name string token string }{ {"not hex", "not-a-checksum-at-all-whatever!!"}, {"too short", strings.Repeat("0", 31)}, {"too long", strings.Repeat("0", 33)}, {"empty", ""}, } for _, tc := range bad { if _, err := parseBody(prefix + "Z " + tc.token + "\n"); err == nil { t.Errorf("early Z card (%s): Parse succeeded, want error", tc.name) } } // Uppercase hex digits are accepted: §6.1 defines the token as // md5-token = 32hexdig-ci, a parse-accept grammar that is explicitly // case-insensitive. Verification stays byte-exact against the // lowercase form (§6.2), which VerifyZ handles separately. for _, good := range []string{strings.Repeat("0", 32), strings.Repeat("A", 32), strings.Repeat("f", 32)} { if _, err := parseBody(prefix + "Z " + good + "\n"); err != nil { t.Errorf("early Z card %q: Parse: %v", good, err) } } } // TestPCardsAreNotSortedOrDeduplicated pins the P exception in §4.5.2: P // order is semantic — the primary parent comes first — so a descending or // repeating run is valid and must survive parsing unchanged. Sorting or // deduplicating P would silently break parent resolution. func TestPCardsAreNotSortedOrDeduplicated(t *testing.T) { body := "P " + hashN('c') + " " + hashN('a') + "\nP " + hashN('b') + " " + hashN('a') + "\n" d, err := parseBody(checkinCards(body)) if err != nil { t.Fatalf("Parse: %v", err) } want := []string{hashN('c'), hashN('a'), hashN('b'), hashN('a')} if len(d.P) != len(want) { t.Fatalf("P = %v, want %v", d.P, want) } for i := range want { if d.P[i] != want[i] { t.Fatalf("P = %v, want %v", d.P, want) } } } // TestQCardsAreUnorderedRepeatable pins the Q exception in §4.5.2: Q has // no duplicate guard and no ordering check at all. func TestQCardsAreUnorderedRepeatable(t *testing.T) { body := "Q +" + hashN('c') + "\nQ +" + hashN('a') + "\nQ +" + hashN('a') + "\n" d, err := parseBody(checkinCards(body)) if err != nil { t.Fatalf("Parse: %v", err) } if len(d.Q) != 3 { t.Fatalf("Q = %+v, want 3 entries", d.Q) } if d.Q[0].Target != hashN('c') || d.Q[1].Target != hashN('a') || d.Q[2].Target != hashN('a') { t.Fatalf("Q = %+v, want descending run with a duplicate preserved", d.Q) } } // TestZCardHasNoDuplicateOrLastCardGuard pins §4.5.1: the Z card belongs // to neither occurrence class. A Z line before the framing checksum is // simply content covered by that checksum, so it must not be rejected as a // duplicate or as a misplaced last card. func TestZCardHasNoDuplicateOrLastCardGuard(t *testing.T) { body := controlCards("T +tag "+hashN('a')+"\n") + "Z " + strings.Repeat("0", 32) + "\n" if _, err := parseBody(body); err != nil { t.Fatalf("earlier Z line: Parse: %v", err) } } // TestMarshalEmitsParseableIntraRunOrder pins that every artifact this // package can produce satisfies the intra-run ordering rules its own // parser now enforces (§4.5.2). The deck below is deliberately built in // non-ascending order for each repeatable type, because callers construct // decks in whatever order suits them and the marshaller owns wire order. // // The T case is the subtle one: sorting T by the concatenation // name+target rather than by the two-level (name, target) key reorders // tags whose names are prefixes of one another, because the target's // leading hex digit then competes with the longer name's next character. // Here "f0..." sorts after "bar", so a concatenated key would emit // "+sym-foobar" ahead of "+sym-foo" and produce an artifact the parser // rejects. func TestMarshalEmitsParseableIntraRunOrder(t *testing.T) { at := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) // A manifest exercises the F and T repeatable runs together -- the T case // is the subtle one described above -- in an artifact §5.2 accepts. Each // run is built in non-ascending order so Marshal must reorder it. manifest := &Deck{ D: at, F: []FileCard{ {Name: "zeta.txt", UUID: hashN('a')}, {Name: "alpha.txt", UUID: hashN('b')}, }, T: []TagCard{ {Type: TagSingleton, Name: "sym-foo", UUID: "f" + strings.Repeat("0", 39)}, {Type: TagSingleton, Name: "sym-foobar", UUID: strings.Repeat("0", 40)}, }, U: User("alice"), } // A ticket exercises the J run, and a cluster the M run; neither card may // share an artifact with F, so each gets its own valid artifact. ticket := &Deck{ D: at, J: []TicketField{ {Name: "title", Value: "Test ticket"}, {Name: "status", Value: "Open"}, }, K: hashN('e'), U: User("alice"), } cluster := &Deck{M: []string{hashN('c'), hashN('a')}} for _, d := range []*Deck{manifest, ticket, cluster} { data, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } if _, err := Parse(data); err != nil { t.Fatalf("Parse of marshalled deck: %v\n%s", err, data) } } } // TestMarshalSortsTagsByDecodedName pins that the marshaller orders T // cards by the same decoded key the parser checks (§4.7.16). TagCard.Name // holds the decoded form, so a name whose decoded byte (a space, 0x20) // sorts before another's ('-', 0x2D) must emit first even though its // escaped wire form ("sym-a\sb") sorts the other way; sorting on the wire // form would emit a run the parser rejects. func TestMarshalSortsTagsByDecodedName(t *testing.T) { // A valid control artifact: D, the tags (targeting other artifacts), U. d := &Deck{ D: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), T: []TagCard{ {Type: TagPropagating, Name: "sym-a-c", UUID: hashN('b')}, {Type: TagPropagating, Name: "sym-a b", UUID: hashN('a')}, }, U: User("u"), } data, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } if !strings.Contains(string(data), "T *sym-a\\sb "+hashN('a')+"\nT *sym-a-c "+hashN('b')+"\n") { t.Fatalf("T cards not emitted in decoded-name order:\n%s", data) } if _, err := Parse(data); err != nil { t.Fatalf("Parse of marshalled deck: %v\n%s", err, data) } } // TestCompare pins the canonical comparator of §4.5.3: raw-byte // comparison of unsigned octets, no case folding, no Unicode // normalization, a prefix before any extension of itself, an absent value // before any present one, and no special treatment for '/' (0x2F). func TestCompare(t *testing.T) { cases := []struct { a, b string want int }{ {"", "", 0}, {"abc", "abc", 0}, {"", "a", -1}, // absent sorts before present {"abc", "abcd", -1}, // prefix sorts before its extension {"A", "a", -1}, // no case folding: 0x41 < 0x61 {"Z", "a", -1}, // no case folding: 0x5A < 0x61 {"\x7e", "\x80", -1}, // octets compared as unsigned values {"\x80", "\x7e", 1}, // {"a.b", "a/b", -1}, // '/' (0x2F) gets no special treatment {"a-b", "a/b", -1}, // {"a/b", "a0b", -1}, // {"a b", "a-b", -1}, // 0x20 < 0x2D {"\xc3\xa9", "z", 1}, // no Unicode normalization or collation } for _, tc := range cases { if got := Compare(tc.a, tc.b); got != tc.want { t.Errorf("Compare(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) } } }