Artifact 51ab44338cd1c6b35248d988bf6c110dab6edd7df8da4e9f97908859fb66f32f:
- File
internal/deck/occurrence.go
— part of check-in
[0e579e24a4]
at
2026-07-22 23:03:29
on branch trunk
— fix: J/T card escape-encoding symmetry and T-card §4.7.16 validation (#90, #91, #93) (#116)
* fix: J card decodes value, stores name verbatim (#90)
The J-card parser had the escape direction inverted: it decoded the field name and exposed the value with its escape sequences intact. Both the spec (4.7.8) and canonical fossil (manifest.c) do the opposite -- defossilize only the value, compare the raw name.
Swap both the parse and marshal directions so the value round-trips decoded and the name stays verbatim.
* fix: T card escape-encodes the tag name on marshal (#91)
A tag/branch name containing a space was written verbatim, so 'my branch' produced 'T *sym-my branch *' -- a structurally valid but silently wrong card that re-parses as two tokens.
Align with canonical fossil: escape-encode the name on marshal and escape-decode it on parse, leaving the target token verbatim in both directions (4.7.16). TagCard.Name now holds the decoded form, so tagOrderKey reattaches the sign without re-decoding.
* fix: enforce two skipped T-card §4.7.16 checks on parse (#93)
parseTCard validated the sign and ordering key but accepted two malformed shapes from untrusted peers:
- a name that after the sign is entirely hex digits, indistinguishable from a hash target; - a target that is neither a valid artifact hash nor the literal '*'.
Reject both with errors (never a panic). hash.IsValidHash reuses the same target check the primary uuid path uses; the '*' and non-empty guards run first since it panics on an empty string.
* fix: T card decodes and encodes the tag value (#91), guard hex names on marshal (#93)
§4.7.16 applies the same escape rule to all three T-card tokens: the name and value are escape-decoded on parse and escape-encoded on marshal, while the target is stored verbatim. Only the name was handled; the value was passed through raw in both directions.
The read side was the real damage. `fossil branch new 'my branch'` writes
T *branch * my\sbranch T *sym-my\sbranch *
so importing an ordinary fossil repo with a spaced branch name produced a branch literally named "my\sbranch" through crosslink. Verified against the fossil binary: the artifact for that command now parses to value "my branch" and re-marshals byte-for-byte identical.
Marshal also gains the mirror of the hex-only tag name check parse already enforces. Without it a caller could build and store an artifact this repository could never read back; canonical fossil never emits one either, since it prefixes branch and user tags with "sym-". (user: [email protected], size: 3474) [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, in decoded form (§4.7.16). // Ordering is the 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 // decoded names rejects runs canonical fossil accepts. // // TagCard splits the sign into Type and stores Name already decoded (the // parser defossilizes it), so the key is just the sign reattached to the // name. 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 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) }