Artifact e6a8b5be3b2fcb54b4d0b920192fab24f6e7344dd450fed11dc10ff8d5c34809:
- File
internal/deck/occurrence.go
— part of check-in
[5036cae392]
at
2026-07-19 22:27:35
on branch trunk
— Enforce card occurrence classes and intra-run ordering (#94)
* Enforce card occurrence classes and intra-run ordering
The deck parser enforced card-type ordering and nothing else. Each of the 14 single-occurrence card types was handled by a case that assigned, so a second occurrence silently overwrote the first, and the repeatable types were appended with no sort check at all. draft-fossil-artifact-format-00 §4.5.1 and §4.5.2 define both rules; implement them.
Duplicate rejection covers A, B, C, D, E, G, H, I, K, L, N, R, U and W. The guard is separate from the card-type check because that check permits equal consecutive letters. F, J and M gain a strictly-ascending check against the preceding entry, T the two-level (name, target hash) rule with the full sign-prefixed name as primary key. P and Q deliberately get neither: P order is semantic — primary parent first — and Q is unordered-repeatable. P also now appends across cards instead of replacing, which is what §4.5.1 means by repeatable. Z belongs to neither class, so it is accepted with no duplicate or last-card rule; the authoritative checksum is the trailing 35 bytes VerifyZ already verified.
All four ordering checks route through one comparator, deck.Compare, which is §4.5.3: unsigned octets, locale-independent, no normalization, no case folding. §4.5.3 exists because the same ordering underlies the delta-manifest merge walk and baseline binary search of §8.2, so a second copy would resolve a delta manifest wrongly rather than merely fail validation. The F check runs on the decoded filename, per §4.5.2.
Malformed input is reported through the error return, never a panic -- these bytes arrive from peers.
Two writer-side defects surfaced, both of which let this package emit artifacts its own parser now rejects. Marshal emitted J cards in caller-supplied order while already sorting F, M and T; it now sorts them. Marshal also sorted T by the concatenation name+target, which lets the target's leading hex digit compete with the tail of a longer name and reorders tags whose names are prefixes of one another; it now uses the two-level key. The F and M sorts and the R-card file enumeration route through deck.Compare as well, the latter per §6.3.
Closes #70
* Order T cards by decoded name and type the Z payload
Review found two ways the first pass diverged from canonical fossil in the accept/reject direction.
The T ordering key used the raw wire name. §4.7.16 says the name is escape-decoded and that the primary ordering key is the full name token including its leading sign character; manifest.c defossilizes the name before the ordering compare. Because "\s" begins 0x5C and a space is 0x20, the two forms order oppositely, so a run like
T *sym-a\sb aaaa... T *sym-a-c bbbb...
is valid, fossil accepts it, and we rejected it. Branch names may contain spaces, so this is an ordinary tag, not a corner case -- the same post-decode trap already handled for F. tagOrderKey now builds the decoded key and both the parser check and the marshaller's sort call it, so the two cannot drift apart.
The Z card accepted any payload. Accepting a Z line inside the body is right -- §4.5.1 puts Z in neither occurrence class and treats an earlier Z as content covered by the final checksum -- but §4.7.19 still fixes the payload at exactly 32 hexadecimal characters. Validating nothing left an arbitrary-byte channel in an otherwise fully typed grammar and would have stored and forwarded artifacts canonical refuses. Upper-case digits are accepted, matching fossil's validate16.
Also: the T sign prefix is now checked against {+,-,*} per §4.7.16, both because canonical checks it and because an unvalidated sign byte >= 0x80 would UTF-8-expand to two bytes when the ordering key is assembled and silently corrupt the comparison. Two stale §4.7.20 citations corrected to §4.7.16, and the two-level T test gained the prefix-name case that actually distinguishes a two-level comparison from a concatenated one.
* Cite the normative grammar for the case-insensitive Z token
The Z payload's case-insensitivity was justified by inferring it from the word "hexadecimal" in §4.7.19. §6.1 states it normatively: md5-token = 32hexdig-ci, a parse-accept grammar admitting both 0-9a-f and A-F. Cite that instead, and record the split it implies -- the structural check is case-insensitive while §6.2 keeps verification byte-exact against the lowercase form.
Also soften deck.Compare's claim that the delta-manifest merge walk relies on F ordering to the future tense parse.go already uses: the merge walk is a map overlay today with no comparator to share. (user: [email protected], size: 3479) [annotate] [blame] [check-ins using]
package deck import ( "fmt" "strings" ) // Compare is the canonical comparator of draft-fossil-artifact-format-00 // §4.5.3: a raw-byte comparison in which octets are compared as unsigned // values, locale-independent, with no Unicode normalization and no case // folding. A shorter string sorts before any extension of itself, an // absent value (the empty string) sorts before any present value, and the // directory separator '/' (0x2F) receives no special treatment. It returns // a negative number, zero, or a positive number as a sorts before, equal // to, or after b. // // §4.5.3 defines one comparator shared by the F, J, M and T sort checks, // the delta-baseline merge walk (§8.2) and single-name search (§8.4). A // second copy anywhere is a correctness hazard, not a style issue: F // ordering is what the baseline binary search and the delta-manifest merge // walk will rely on, so two comparators that disagree resolve a delta manifest // wrongly rather than merely failing validation. Route every such // comparison through here. func Compare(a, b string) int { return strings.Compare(a, b) } // singleOccurrence marks, by letter offset from 'A', the 14 card types of // §4.5.1 whose duplicate MUST be rejected. F, J, M, T, P and Q are // repeatable and each occurrence appends. The Z card belongs to neither // class: it has no duplicate guard, because §4.5.1 derives "exactly one Z, // last" from the checksum framing rather than from a card rule, and an // earlier Z line is simply content covered by the final checksum. var singleOccurrence = func() [26]bool { var s [26]bool for _, c := range "ABCDEGHIKLNRUW" { s[c-'A'] = true } return s }() // seenCards tracks which single-occurrence card types a parse has already // consumed. The zero value is ready to use. type seenCards [26]bool // claim records one occurrence of card and reports an error if the type is // single-occurrence and has been seen before (§4.5.1). Card letters outside // A-Z are left to the parser's unknown-card path. func (s *seenCards) claim(card byte) error { if card < 'A' || card > 'Z' { return nil } i := card - 'A' if !singleOccurrence[i] { return nil } if s[i] { return fmt.Errorf("duplicate '%c' card", card) } s[i] = true return nil } // tagOrderKey returns the primary ordering key for a T card: the full name // token including its leading sign character, escape-decoded (§4.7.16). // The decode is the same post-decode rule §4.5.2 spells out for F — a // name reaches the wire as "sym-my\sbranch" and orders as "sym-my branch", // so comparing raw tokens rejects runs canonical fossil accepts. // // TagCard splits the sign into Type and stores Name in raw wire form, so // the key is reassembled here rather than read off a single field. Both // the parser's ordering check and the marshaller's sort call this, so // neither can drift from the other. func tagOrderKey(t TagCard) string { return FossilDecode(string(t.Type) + t.Name) } // requireAscending enforces the single-key strictly-ascending intra-run // rule of §4.5.2 for the F, J and M card lists: equal or descending // neighbours are rejected. The key is always the parser's decoded form, // which for F is the post-decode filename rather than the raw wire token. func requireAscending(card byte, previous, current string) error { if Compare(current, previous) > 0 { return nil } return fmt.Errorf("%c-card %q out of order after %q", card, current, previous) }