Artifact 25a7bd8b88be8d987dfe2aa02eb496a764ce5b8ddac5beb44fcfce6882ca323b:
- File
internal/deck/parse.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: 21215) [annotate] [blame] [check-ins using]
package deck import ( "fmt" "strings" "time" "github.com/danmestas/go-libfossil/internal/hash" ) // isHexToken reports whether s is non-empty and consists solely of the // lowercase hex digits an artifact hash is built from. §4.7.16 rejects a // T-card name that is entirely hex because it is indistinguishable from a // hash target. func isHexToken(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { c := s[i] isHexDigit := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') if !isHexDigit { return false } } return true } // manifestScanner tokenizes clearsign-stripped artifact content card by card, // following fossil's next_token/next_card (src/manifest.c). Tokens on a card // are separated by a single space; a card ends at LF. Tab and CR are ordinary // token bytes, never separators, so a card value may carry them -- the only // thing a token cannot contain is a space or an LF. The zero value is not // usable: start a scan with atEol true at buf[0]. type manifestScanner struct { buf []byte pos int // index of the next unread byte atEol bool // true when positioned at the first byte of a card } // nextCard returns the next card's type letter and advances past the letter // and the single byte that follows it. A space after the letter means // arguments follow (atEol cleared); an LF means a bare card with none (atEol // kept). Any other following byte -- or no more cards -- yields 0, leaving pos // where the caller's §4.2 residue check will see the unconsumed bytes. func (s *manifestScanner) nextCard() byte { if !s.atEol || s.pos >= len(s.buf) { return 0 } c := s.buf[s.pos] switch { case s.pos+1 < len(s.buf) && s.buf[s.pos+1] == ' ': s.pos += 2 s.atEol = false case s.pos+1 < len(s.buf) && s.buf[s.pos+1] == '\n': s.pos += 2 s.atEol = true default: return 0 } return c } // nextToken returns the next token on the current card and whether one was // present. A token runs to the next space or LF; the delimiter is consumed, // and an LF ends the card (atEol set). A card with no arguments, or one whose // tokens are exhausted, yields ok=false (fossil's next_token returning NULL). func (s *manifestScanner) nextToken() (token string, ok bool) { if s.atEol { return "", false } start := s.pos for s.pos < len(s.buf) && s.buf[s.pos] != ' ' && s.buf[s.pos] != '\n' { s.pos++ } token = string(s.buf[start:s.pos]) if s.pos < len(s.buf) && s.buf[s.pos] == '\n' { s.atEol = true } s.pos++ // consume the delimiter (space or LF) return token, true } // cardTokens drains and returns every token of the current card. §4.2 with // its empty-trailing-token erratum: a token between two others may not be // empty -- two spaces in a row -- but the last token may be, carrying a // present-but-empty value (an empty comment, J value, N mimetype or T value). // fossil has no such explicit rule; its per-card validators reject an interior // empty token at whichever field it lands on, and this is their shared form. func (s *manifestScanner) cardTokens() ([]string, error) { var tokens []string for { tok, ok := s.nextToken() if !ok { break } tokens = append(tokens, tok) } for i := 0; i+1 < len(tokens); i++ { if tokens[i] == "" { return nil, fmt.Errorf("empty token between two others") } } return tokens, nil } // parseW reads a W card's body. The size is a token fossil parses as its // leading decimal digits (W 12x is 12, W abc is 0), which must be the card's // only token; then exactly that many raw bytes, which are never decoded, // followed by a required LF. Mirrors the W case of manifest_parse. func (s *manifestScanner) parseW(d *Deck) error { if d.W != nil { return fmt.Errorf("more than one W-card") } sizeTok, ok := s.nextToken() if !ok { return fmt.Errorf("missing size on W-card") } if !s.atEol { return fmt.Errorf("no content after W-card") } var size uint64 for i := 0; i < len(sizeTok) && sizeTok[i] >= '0' && sizeTok[i] <= '9'; i++ { next := size*10 + uint64(sizeTok[i]-'0') if next < size { return fmt.Errorf("size overflow on W-card") } size = next } // fossil requires the body and its terminating LF to leave at least one // more byte before the end, so a W card is never the artifact's last card. if size+1 >= uint64(len(s.buf)-s.pos) { return fmt.Errorf("not enough content after W-card") } n := int(size) if s.buf[s.pos+n] != '\n' { return fmt.Errorf("W-card content not newline terminated") } // Copy so the deck does not alias the input buffer. d.W = append([]byte{}, s.buf[s.pos:s.pos+n]...) s.pos += n + 1 s.atEol = true return nil } // Parse reads an artifact as a pipeline of stages: frame the raw blob (§4.1 // framing, wrapper strip, §4.1.1 first card, §6.2 Z checksum), scan the cards // (§4.2), then determine the type (§5.3). Each stage is a helper so Parse // reads as the pipeline and no stage runs before the one it depends on. func Parse(data []byte) (*Deck, error) { if data == nil { panic("deck.Parse: data must not be nil") } content, err := frameArtifact(data) if err != nil { return nil, fmt.Errorf("deck.Parse: %w", err) } d := &Deck{} if err := scanCards(content, d); err != nil { return nil, fmt.Errorf("deck.Parse: %w", err) } d.Type = inferType(d) return d, nil } // frameArtifact validates an artifact's framing and returns the inner content // the card scanner reads. It mirrors the order fossil applies these checks in, // up front before the card loop (src/manifest.c): §4.1 (non-empty, ends in LF) // on the raw blob, the PGP/SSH clearsign wrapper stripped, §4.1.1 (the first // card is an upper-case letter and a space), then the §6.2 Z self-checksum. // The clearsign strip is a byte-for-byte identity on the common unsigned case, // and the artifact's content-addressed hash is taken over the raw blob // elsewhere, so this never changes which UUID an artifact resolves to. func frameArtifact(data []byte) ([]byte, error) { if len(data) == 0 { return nil, fmt.Errorf("zero-length artifact") } if data[len(data)-1] != '\n' { return nil, fmt.Errorf("artifact not terminated with a newline") } content := stripClearsign(data) if len(content) < 10 || content[0] < 'A' || content[0] > 'Z' || content[1] != ' ' { return nil, fmt.Errorf("line 1 not recognized") } // verifyZ reports a present-but-wrong checksum; an absent Z card is left to // the card loop and the required-card rule. The Z card itself is parsed // below as an ordinary card, so the whole content is scanned. if err := verifyZ(content); err != nil { return nil, err } return content, nil } // scanCards runs the §4.2 card loop over content, filling d. Every structural // artifact type requires a Z card (§6.2 / §5.2), so a scan reaching the end // without one is rejected here; the full per-type required-card table is // applied in Parse's later stages. func scanCards(content []byte, d *Deck) error { s := &manifestScanner{buf: content, atEol: true} var lastCard byte var seen seenCards var sawZ bool for { card := s.nextCard() if card == 0 { break } if card == 'Z' { sawZ = true } // §4.5.1 single-occurrence guard, kept separate from the ordering // check below because that one permits equal consecutive letters. if err := seen.claim(card); err != nil { return err } // §4.4: cards ascend, with the one legacy technote N-after-P exception. if card < lastCard && !isLegacyTechnoteNAfterP(d, card, lastCard) { return fmt.Errorf("card '%c' out of order (after '%c')", card, lastCard) } lastCard = card if card == 'W' { if err := s.parseW(d); err != nil { return err } continue } tokens, err := s.cardTokens() if err != nil { return err } if err := parseCard(d, card, tokens); err != nil { return err } } // §4.2: any byte left once no card can be read -- a blank line, trailing // residue, or an extra token that stopped the scan early -- is an error // (fossil's "extra characters at end of card"). if s.pos < len(s.buf) { return fmt.Errorf("extra characters at end of card") } // §6.2 / §5.2: an artifact that reaches here without a Z card is not well // formed. verifyZ treated the absent Z card as "continue"; this is where // its absence is judged. if !sawZ { return fmt.Errorf("Z-card missing") } return nil } // isLegacyTechnoteNAfterP reports whether an N-card following a P-card is the // one out-of-order sequence Fossil accepts. // // Cards are otherwise required to be in lexicographical order, but a technote // generator bug shipped before 2021-02-10 emitted the P-card ahead of the // N-card, and the ordering check that would have caught it was itself broken // until the same date. Fossil keeps accepting those artifacts for historical // compatibility (src/manifest.c, tickets 15d04de574383d61 and 5e67a7f4041a36ad) // and so must this parser: rejecting them dropped real technote revisions -- // along with their event-<id> and bgcolor tags -- out of crosslink (issue #193). // // The exception is narrow on purpose: only N after P, and only once the E-card // that makes the artifact a technote has already been seen. func isLegacyTechnoteNAfterP(d *Deck, card, lastCard byte) bool { return card == 'N' && lastCard == 'P' && d.E != nil } func parseCard(d *Deck, card byte, tokens []string) error { if d == nil { panic("deck.parseCard: d must not be nil") } switch card { case 'A': return parseACard(d, tokens) case 'B': return setStringCard(tokens, &d.B, "hash on B-card", false) case 'C': return setStringCard(tokens, &d.C, "comment on C-card", true) case 'D': return parseDCard(d, tokens) case 'E': return parseECard(d, tokens) case 'F': return parseFCard(d, tokens) case 'G': return setStringCard(tokens, &d.G, "hash on G-card", false) case 'H': return setStringCard(tokens, &d.H, "title on H-card", true) case 'I': return setStringCard(tokens, &d.I, "hash on I-card", false) case 'J': return parseJCard(d, tokens) case 'K': return setStringCard(tokens, &d.K, "hash on K-card", false) case 'L': return setStringCard(tokens, &d.L, "title on L-card", true) case 'M': return parseMCard(d, tokens) case 'N': return setStringCard(tokens, &d.N, "mimetype on N-card", false) case 'P': return parsePCard(d, tokens) case 'Q': return parseQCard(d, tokens) case 'R': return setStringCard(tokens, &d.R, "checksum on R-card", false) case 'T': return parseTCard(d, tokens) case 'U': return parseUCard(d, tokens) case 'Z': return parseZCard(tokens) default: return fmt.Errorf("unknown card '%c'", card) } } // setStringCard stores a single-token card value. A bare card with no token // at all is "missing"; a present-but-empty token -- a card line ending in a // space, per the §4.2 erratum -- is kept distinct from that. decode applies // §4.3 fossil-decoding to the fields §4.3.1 lists as decoded (C, H, L). func setStringCard(tokens []string, dst *string, what string, decode bool) error { if len(tokens) == 0 { return fmt.Errorf("missing %s", what) } if len(tokens) > 1 { return fmt.Errorf("extra characters after %s", what) } value := tokens[0] if decode { value = FossilDecode(value) } *dst = value return nil } func parseACard(d *Deck, tokens []string) error { // §4.2.1 / Table 2: filename, target, optional source -- three tokens at // most. An extra token is "extra characters at end of card". if len(tokens) < 2 || len(tokens) > 3 { return fmt.Errorf("A-card takes a filename, a target and an optional source") } ac := &AttachmentCard{Filename: FossilDecode(tokens[0]), Target: tokens[1]} if len(tokens) >= 3 { ac.Source = tokens[2] } d.A = ac return nil } func parseDCard(d *Deck, tokens []string) error { if len(tokens) != 1 { return fmt.Errorf("D-card takes exactly one timestamp") } t, err := parseTimestamp(tokens[0]) if err != nil { return fmt.Errorf("D-card: %w", err) } d.D = t return nil } func parseECard(d *Deck, tokens []string) error { // §4.2.1: exactly a timestamp and an event id. if len(tokens) != 2 { return fmt.Errorf("E-card takes a timestamp and a uuid") } t, err := parseTimestamp(tokens[0]) if err != nil { return fmt.Errorf("E-card: %w", err) } d.E = &EventCard{Date: t, UUID: tokens[1]} return nil } func parseFCard(d *Deck, tokens []string) error { // §4.2.1 / Table 2: filename, optional hash, permission and old-name -- // four tokens at most. if len(tokens) == 0 || len(tokens) > 4 { return fmt.Errorf("F-card takes a filename and up to three more fields") } fc := FileCard{Name: FossilDecode(tokens[0])} if len(tokens) >= 2 { fc.UUID = tokens[1] } if len(tokens) >= 3 { fc.Perm = tokens[2] } if len(tokens) >= 4 { fc.OldName = FossilDecode(tokens[3]) } // §4.5.2: strictly ascending by decoded filename. Decoding has already // happened above, so the check sees the post-decode name the merge walk // and baseline binary search of §8.2 will later rely on. if n := len(d.F); n > 0 { if err := requireAscending('F', d.F[n-1].Name, fc.Name); err != nil { return err } } d.F = append(d.F, fc) return nil } func parseMCard(d *Deck, tokens []string) error { if len(tokens) != 1 { return fmt.Errorf("M-card takes exactly one hash") } m := tokens[0] // §4.5.2: strictly ascending by hash. if n := len(d.M); n > 0 { if err := requireAscending('M', d.M[n-1], m); err != nil { return err } } d.M = append(d.M, m) return nil } func parseJCard(d *Deck, tokens []string) error { // §4.2.1: a field name and an optional value -- two tokens at most. if len(tokens) == 0 || len(tokens) > 2 { return fmt.Errorf("J-card takes a name and an optional value") } // §4.7.8: the value is escape-decoded, the field name is stored verbatim // (canonical manifest.c defossilizes only zValue and compares the raw // zName). jf := TicketField{Name: tokens[0]} if len(tokens) >= 2 { jf.Value = FossilDecode(tokens[1]) } // §4.5.2: strictly ascending by field name. if n := len(d.J); n > 0 { if err := requireAscending('J', d.J[n-1].Name, jf.Name); err != nil { return err } } d.J = append(d.J, jf) return nil } func parsePCard(d *Deck, tokens []string) error { // Repeatable, and neither sorted nor deduplicated (§4.5.2): P order is // semantic, primary parent first. Appending -- not assigning -- is what // makes a second P card add parents rather than replace them (§4.5.1). A // bare P card carries no parents, which is the initial empty baseline. d.P = append(d.P, tokens...) return nil } func parseQCard(d *Deck, tokens []string) error { // §4.2.1: a signed target and an optional baseline -- two tokens at most. if len(tokens) == 0 || len(tokens) > 2 || len(tokens[0]) < 1 { return fmt.Errorf("Q-card takes a signed target and an optional baseline") } cp := CherryPick{IsBackout: tokens[0][0] == '-'} cp.Target = tokens[0][1:] if len(tokens) >= 2 { cp.Baseline = tokens[1] } d.Q = append(d.Q, cp) return nil } func parseTCard(d *Deck, tokens []string) error { if len(tokens) == 0 || len(tokens[0]) < 1 { return fmt.Errorf("T-card too short") } nameToken := tokens[0] // §4.7.16: the first character of the name token MUST be +, - or *. // Rejecting anything else also keeps the sign a single byte, so the // ordering key assembled from it cannot silently gain a second byte // through UTF-8 expansion. switch TagType(nameToken[0]) { case TagSingleton, TagPropagating, TagCancel: default: return fmt.Errorf("T-card name must begin with '+', '-' or '*', got %q", nameToken[:1]) } // §4.2.1: a signed name, a target and an optional value -- three tokens. if len(tokens) < 2 || len(tokens) > 3 { return fmt.Errorf("T-card takes a name, a target and an optional value") } tc := TagCard{Type: TagType(nameToken[0])} // §4.7.16: the name and value tokens are escape-decoded (canonical // defossilizes both), while the target token is stored verbatim -- never // decoded on the way in, so it is never encoded on the way out. Decoding // the value matters on ordinary input: `fossil branch new 'my branch'` // writes `T *branch * my\sbranch`, and leaving that raw names the branch // literally "my\sbranch" all the way through crosslink. tc.Name = FossilDecode(nameToken[1:]) tc.UUID = tokens[1] if len(tokens) >= 3 { tc.Value = FossilDecode(tokens[2]) } // §4.7.16: a name that after the sign is entirely hex digits is ambiguous // with a hash, so reject it. The escape-decoded name is used; an all-hex // token carries no escapes, so decoding leaves it unchanged. if isHexToken(tc.Name) { return fmt.Errorf("T-card name %q must not be entirely hexadecimal", tc.Name) } // §4.7.16: the target must be a valid artifact hash or the literal '*'. // hash.IsValidHash panics on an empty string, so the '*' and non-empty // checks come first. validTarget := tc.UUID == "*" if !validTarget && tc.UUID != "" { validTarget = hash.IsValidHash(tc.UUID) } if !validTarget { return fmt.Errorf("T-card target %q is neither a valid artifact hash nor '*'", tc.UUID) } if n := len(d.T); n > 0 { if err := requireTagAscending(d.T[n-1], tc); err != nil { return err } } d.T = append(d.T, tc) return nil } // requireTagAscending enforces the two-level T rule of §4.5.2: ascending by // tag name, then by target hash, with equal names requiring a strictly // increasing hash. Per §4.7.16 the primary key is the decoded full name // token including its leading sign character; tagOrderKey builds it. func requireTagAscending(previous, current TagCard) error { previousName := tagOrderKey(previous) currentName := tagOrderKey(current) switch cmp := Compare(currentName, previousName); { case cmp > 0: return nil case cmp < 0: return fmt.Errorf("T-card %q out of order after %q", currentName, previousName) default: if Compare(current.UUID, previous.UUID) > 0 { return nil } return fmt.Errorf("T-card %q target %q out of order after %q", currentName, current.UUID, previous.UUID) } } // parseUCard resolves the U-card's decoded value the same way fossil's // own parser does (src/manifest.c:1008-1016): a present-but-empty U-card // becomes the literal "anonymous". A wholly absent U-card never calls // this function at all, so d.U stays nil — the third state a bare string // field cannot represent. Resolving here, once, means every downstream // crosslink call site can bind d.U directly and get the right SQL value // (NULL, "anonymous", or the login) without repeating this logic. func parseUCard(d *Deck, tokens []string) error { // §4.2.1: an optional login -- one token at most. if len(tokens) > 1 { return fmt.Errorf("U-card takes an optional login") } var user string if len(tokens) > 0 { user = FossilDecode(tokens[0]) } if user == "" { user = "anonymous" } d.U = &user return nil } // parseZCard validates the trailing Z card as an ordinary card. §4.7.19 fixes // its payload at exactly 32 hexadecimal characters (never decoded) and it // carries exactly one token; an extra token is "extra characters" the same as // on any other card. The Z line's integrity is checked separately by verifyZ. func parseZCard(tokens []string) error { if len(tokens) != 1 { return fmt.Errorf("Z-card takes exactly one token") } token := tokens[0] if len(token) != 32 { return fmt.Errorf("Z-card checksum must be 32 characters, got %d", len(token)) } for i := 0; i < len(token); i++ { if !isHexDigit(token[i]) { return fmt.Errorf("Z-card checksum is not hexadecimal: %q", token) } } return nil } func parseTimestamp(s string) (time.Time, error) { s = strings.TrimSpace(s) for _, layout := range []string{ "2006-01-02T15:04:05.000", "2006-01-02T15:04:05", } { if t, err := time.Parse(layout, s); err == nil { return t, nil } } s = strings.Replace(s, "t", "T", 1) for _, layout := range []string{ "2006-01-02T15:04:05.000", "2006-01-02T15:04:05", } { if t, err := time.Parse(layout, s); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("cannot parse timestamp %q", s) } func inferType(d *Deck) ArtifactType { switch { case len(d.M) > 0: return Cluster case d.G != "" || d.H != "" || d.I != "": return ForumPost case d.A != nil: return Attachment case d.K != "": return Ticket case d.L != "": return Wiki case d.E != nil: return Event // Canonical Fossil settles the remaining ambiguity -- check-in versus // control artifact, both of which are D/T/U/Z-shaped -- on whether the // artifact describes a tree position: a comment, files, or parents // (src/manifest.c, "If the artifact type has not yet been determined"). // The C-card is the part that is easy to miss, and it matters: `fossil // merge --integrate` writes a check-in that has a comment and a `T +closed` // card but no F-cards and no P-card, and reading those as control // artifacts lost their tags (issue #193). The R-card is not part of // canonical's test but implies a file tree, so it stays here. case d.C != "" || len(d.F) > 0 || len(d.P) > 0 || d.R != "": return Checkin case len(d.T) > 0: return Control default: return Checkin } }