package deck
import (
"fmt"
"strings"
)
// isHexToken reports whether s is non-empty and consists solely of hex
// digits of either case. §4.7.16 rejects a T-card name that is entirely hex
// because it is indistinguishable from a hash target; fossil's check
// (validate16 on the name after its sign) is case-insensitive, so an
// upper-case all-hex name such as "ABC" is rejected the same as "abc".
func isHexToken(s string) bool {
return s != "" && isHexAnyCase(s)
}
// isHexAnyCase reports whether every byte of s is a base-16 digit in either
// case. §3.5 / §4.7.1: artifact-hash tokens are case-insensitive hexadecimal.
func isHexAnyCase(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// validArtifactHash reports whether s is a syntactically valid artifact-hash
// token per §4.7.1: exactly 40 or 64 case-insensitive hexadecimal characters.
// Unlike hash.IsValidHash it never panics, so it is safe on an empty or
// mangled token straight off the wire.
func validArtifactHash(s string) bool {
return (len(s) == 40 || len(s) == 64) && isHexAnyCase(s)
}
// 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: 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++ {
digit := uint64(sizeTok[i] - '0')
if size > (^uint64(0)-digit)/10 {
return fmt.Errorf("size overflow on W-card")
}
size = size*10 + digit
}
// 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.
remaining := len(s.buf) - s.pos
if remaining < 2 || size >= uint64(remaining-1) {
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{}
seenMask, err := scanCards(content, d)
if err != nil {
return nil, fmt.Errorf("deck.Parse: %w", err)
}
// §5.3: determine the artifact type, then apply §5.2 card legality (the
// cards this type allows and requires, which includes the universal Z
// card) and the §5.4 per-type semantic checks.
d.Type = inferType(d)
// draft-fossil-submanifests-00 §4.3: once the type is final, a manifest
// with no D card is a sub-manifest. This is the only way to reach that
// type, and it runs before legality, which then refuses any D-less
// artifact carrying a card other than F, S or Z.
if d.Type == Checkin && seenMask&(1<<('D'-'A')) == 0 {
d.Type = Submanifest
}
if err := checkType(d, seenMask); err != nil {
return nil, fmt.Errorf("deck.Parse: %w", err)
}
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, and returns the
// mask of card types it saw (bit letter-'A' per type) for the §5.2 legality
// check Parse applies next -- which, since every type requires a Z card,
// is also what rejects an artifact missing one.
func scanCards(content []byte, d *Deck) (uint32, error) {
s := &manifestScanner{buf: content, atEol: true}
var lastCard byte
var seen seenCards
var seenMask uint32 // bit (letter-'A') set for every card type seen
for {
card := s.nextCard()
if card == 0 {
break
}
if card >= 'A' && card <= 'Z' {
seenMask |= 1 << (card - 'A')
}
// §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 0, err
}
// §4.4: cards ascend, with the one legacy technote N-after-P exception.
if card < lastCard && !isLegacyTechnoteNAfterP(d, card, lastCard) {
return 0, fmt.Errorf("card '%c' out of order (after '%c')", card, lastCard)
}
lastCard = card
if card == 'W' {
if err := s.parseW(d); err != nil {
return 0, err
}
continue
}
tokens, err := s.cardTokens()
if err != nil {
return 0, err
}
if err := parseCard(d, card, tokens); err != nil {
return 0, 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 0, fmt.Errorf("extra characters at end of card")
}
return seenMask, 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 setHashCard(tokens, &d.B, "B-card", false)
case 'C':
d.cPresent = true
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 setHashCard(tokens, &d.G, "G-card", false)
case 'H':
return setStringCard(tokens, &d.H, "title on H-card", true)
case 'I':
return setHashCard(tokens, &d.I, "I-card", false)
case 'J':
return parseJCard(d, tokens)
case 'K':
// §4.7.1: the K-card ticket id is pinned to the 40-character SHA-1
// length and never 64, regardless of hash policy.
return setHashCard(tokens, &d.K, "K-card", true)
case 'L':
return parseLCard(d, tokens)
case 'M':
return parseMCard(d, tokens)
case 'N':
// §4.3.1: the N-card mimetype is escape-decoded.
d.nPresent = true
return setStringCard(tokens, &d.N, "mimetype on N-card", true)
case 'P':
return parsePCard(d, tokens)
case 'Q':
return parseQCard(d, tokens)
case 'R':
return setChecksumCard(tokens, &d.R, "R-card")
case 'S':
return parseSCard(d, tokens)
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 §4.2 -- 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
}
// setHashCard stores a single-token artifact-hash card (B, G, I, K), checking
// the token against the §4.7.1 map: exactly 40 or 64 case-insensitive hex, or
// pinned to 40 for the K-card ticket id. The token is never escape-decoded.
func setHashCard(tokens []string, dst *string, card string, sha1Only bool) error {
if len(tokens) != 1 {
return fmt.Errorf("%s takes exactly one hash", card)
}
h := tokens[0]
ok := validArtifactHash(h)
if sha1Only {
ok = len(h) == 40 && isHexAnyCase(h)
}
if !ok {
return fmt.Errorf("invalid hash on %s: %q", card, h)
}
*dst = h
return nil
}
// setChecksumCard stores a single-token MD5 checksum card (R). §4.7.1 / §6.1:
// exactly 32 case-insensitive hex, never decoded.
func setChecksumCard(tokens []string, dst *string, card string) error {
if len(tokens) != 1 {
return fmt.Errorf("%s takes exactly one checksum", card)
}
if token := tokens[0]; len(token) != 32 || !isHexAnyCase(token) {
return fmt.Errorf("invalid checksum on %s: %q", card, token)
}
*dst = tokens[0]
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")
}
// §4.7.2: the filename is escape-decoded, MUST be a simple pathname
// (§4.6.1), and only its final path component is kept (fossil's file_tail);
// the target is escape-decoded -- the one §4.3.1 hash carve-out -- and MUST
// be either an artifact hash or a well-formed wiki name (§4.6.2); the
// source, when present, is a raw artifact hash.
name := FossilDecode(tokens[0])
if !simplePathname(name) {
return fmt.Errorf("invalid filename on A-card: %q", name)
}
target := FossilDecode(tokens[1])
if !validArtifactHash(target) && !wikiNameWellformed(target) {
return fmt.Errorf("invalid target on A-card: %q", target)
}
ac := &AttachmentCard{Filename: fileTail(name), Target: target}
if len(tokens) >= 3 {
ac.Source = tokens[2]
if !validArtifactHash(ac.Source) {
return fmt.Errorf("invalid source hash on A-card: %q", ac.Source)
}
}
d.A = ac
return nil
}
// fileTail returns the final path component of name, the part after the last
// '/'. It mirrors fossil's file_tail for the A-card attachment name.
func fileTail(name string) string {
if i := strings.LastIndexByte(name, '/'); i >= 0 {
return name[i+1:]
}
return name
}
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)
}
// §4.7.1: the event identifier is an artifact hash.
if !validArtifactHash(tokens[1]) {
return fmt.Errorf("invalid hash on E-card: %q", tokens[1])
}
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])}
// §4.6.1: the decoded filename MUST be a simple pathname.
if !simplePathname(fc.Name) {
return fmt.Errorf("invalid filename on F-card: %q", fc.Name)
}
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.6.1: the decoded old-name MUST be a simple pathname too.
if !simplePathname(fc.OldName) {
return fmt.Errorf("invalid old-name on F-card: %q", fc.OldName)
}
}
// §4.7.6 / §4.7.1: a hash is required on every F card of a baseline
// manifest and must be a valid artifact hash (raw, never decoded). Only a
// delta manifest -- one with a B card, which sorts before F and so is
// already seen -- may carry a hashless F card, a file deletion.
if fc.UUID == "" {
if d.B == "" {
return fmt.Errorf("missing hash on F-card %q", fc.Name)
}
} else if !validArtifactHash(fc.UUID) {
return fmt.Errorf("invalid hash on F-card %q: %q", fc.Name, fc.UUID)
}
// §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
}
}
// §4.6.3: a reserved repository filename is silently dropped, AFTER the
// sort-order check above, as fossil does (src/manifest.c, nFile--). The
// dropped card is never stored, so the next card's sort check compares
// against the previous retained file.
if IsReservedName(fc.Name) {
return nil
}
d.F = append(d.F, fc)
return nil
}
// parseSCard reads one S card of draft-fossil-submanifests-00 §3.6: exactly a
// filename and a hash. The filename is escape-decoded and must be a simple
// pathname; the hash is raw and must be 40 or 64 hex digits (§3.5). The run
// ascends strictly by decoded filename under the canonical comparator (§3.4).
// Whether the hash names a sub-manifest, and whether the filename is that
// slice's least member, is the tree walk's to check, not the parser's.
func parseSCard(d *Deck, tokens []string) error {
if len(tokens) != 2 {
return fmt.Errorf("S-card takes exactly a filename and a hash")
}
sc := SubmanifestCard{Name: FossilDecode(tokens[0]), UUID: tokens[1]}
if !simplePathname(sc.Name) {
return fmt.Errorf("invalid filename on S-card: %q", sc.Name)
}
if !validArtifactHash(sc.UUID) {
return fmt.Errorf("invalid hash on S-card %q: %q", sc.Name, sc.UUID)
}
if n := len(d.S); n > 0 {
if err := requireAscending('S', d.S[n-1].Name, sc.Name); err != nil {
return err
}
}
d.S = append(d.S, sc)
return nil
}
func parseLCard(d *Deck, tokens []string) error {
if len(tokens) != 1 {
return fmt.Errorf("L-card takes exactly one wiki title")
}
// §4.6.2: the decoded wiki title MUST be a well-formed wiki name.
title := FossilDecode(tokens[0])
if !wikiNameWellformed(title) {
return fmt.Errorf("malformed wiki name on L-card: %q", title)
}
d.L = title
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.7.1: each cluster member is an artifact hash.
if !validArtifactHash(m) {
return fmt.Errorf("invalid hash on M-card: %q", m)
}
// §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.
// pPresent lets Marshal re-emit that bare "P" even with no parents.
d.pPresent = true
// §4.7.1: every present parent token is an artifact hash.
for _, parent := range tokens {
if !validArtifactHash(parent) {
return fmt.Errorf("invalid hash on P-card: %q", parent)
}
}
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")
}
// §4.7.14: the first token begins with '+' (cherrypick) or '-' (backout);
// §4.7.1: the rest of that token, and the optional baseline, are hashes.
sign := tokens[0][0]
if sign != '+' && sign != '-' {
return fmt.Errorf("Q-card must begin with '+' or '-', got %q", tokens[0][:1])
}
cp := CherryPick{IsBackout: sign == '-', Target: tokens[0][1:]}
if !validArtifactHash(cp.Target) {
return fmt.Errorf("invalid target hash on Q-card: %q", cp.Target)
}
if len(tokens) >= 2 {
cp.Baseline = tokens[1]
if !validArtifactHash(cp.Baseline) {
return fmt.Errorf("invalid baseline hash on Q-card: %q", cp.Baseline)
}
}
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])
tc.valuePresent = true
}
// §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 / §4.7.1: the target must be a valid artifact hash (40 or 64
// case-insensitive hex) or the literal '*' for a tag on this manifest.
if tc.UUID != "*" && !validArtifactHash(tc.UUID) {
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 inferType(d *Deck) ArtifactType {
switch {
// The type-setting cards of §5.3, matching fossil's per-card `p->type`
// assignments (src/manifest.c): M sets cluster, G/H/I forum, A attachment,
// J/K ticket, L wiki, E event, and B/F/Q/R manifest. S joins the manifest
// letters (draft-fossil-submanifests-00 §4.3).
case len(d.M) > 0:
return Cluster
case d.G != "" || d.H != "" || d.I != "":
return ForumPost
case d.A != nil:
return Attachment
case len(d.J) > 0 || d.K != "":
return Ticket
case d.L != "":
return Wiki
case d.E != nil:
return Event
case d.B != "" || len(d.F) > 0 || len(d.Q) > 0 || d.R != "" || len(d.S) > 0:
return Checkin
// No type-setting card: fossil then settles check-in versus control, both
// D/T/U/Z-shaped, on whether the artifact describes a tree position -- a
// comment or a parent (src/manifest.c:1076, "If the artifact type has not
// yet been determined"). fossil keys on card presence (p->zComment != 0,
// p->nParent > 0), not a non-empty value, so a present-but-empty C card
// still makes it a check-in -- hence d.cPresent, not d.C != "". The C-card
// is the easy one to miss, and it matters: `fossil merge --integrate`
// writes a check-in with a comment and a `T +closed` card but no F or P
// card, and reading that as a control artifact lost its tags (issue #193).
// The parent test stays count-based (len(d.P) > 0), matching p->nParent:
// a bare P card with no parents does not make a control a check-in.
case d.cPresent || len(d.P) > 0:
return Checkin
// Otherwise it is a control artifact: a D/T/U/Z artifact with no comment or
// parent -- including one with only tags -- is a control artifact, not an
// empty check-in.
default:
return Control
}
}