Artifact 0be14c0eeefb5e00b3e5ce01ae5ad5b0acd89b171a0fca66962e8817ffe4f86b:
- File
internal/deck/zcard.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: 2702) [annotate] [blame] [check-ins using]
package deck import ( "crypto/md5" "encoding/hex" "fmt" ) // VerifyZ validates a control artifact's trailing Z-card checksum. The blob // may arrive PGP/SSH clearsigned (historical artifacts, e.g. Fossil's own // 2007-era check-ins); stripClearsign isolates the inner content the Z card // is computed over so the last-35-byte check lands on the Z card and not on a // trailing signature block. The artifact's content-addressed hash is computed // over the raw blob elsewhere and is unaffected by this stripping. func VerifyZ(data []byte) error { return verifyZ(stripClearsign(data)) } // verifyZ checks the Z card of already-clearsign-stripped artifact content. // It mirrors fossil's verify_z_card (src/manifest.c): the Z card is located // as the last 35 bytes beginning "Z ", the checksum covers every byte before // it, and the stated digest is the 32 bytes at n-33 -- byte n-1, the final // LF, is never read. §6.2: a body too short to hold a Z card, or one whose // last 35 bytes are not a Z card, has the Z card absent, which this reports // as success and leaves the required-card rule (§5.2) to judge; only a // present-but-wrong checksum is an error here. Framing (§4.1) -- the missing // final LF, residue after the Z card -- is the caller's to check, so this // never rejects an input the rule it breaks should. func verifyZ(body []byte) error { n := len(body) if n < 35 { return nil // no room for a Z card: absent } if body[n-35] != 'Z' || body[n-34] != ' ' { return nil // last 35 bytes are not a Z card: absent } // §6.2 compares byte-exact against the lowercase computeZ, so an // uppercase digest fails verification even though §6.1's grammar admits // it. body[n-1] is deliberately not read. stated := body[n-33 : n-1] computed := computeZ(body[:n-35]) if computed != string(stated) { return fmt.Errorf("deck.VerifyZ: checksum mismatch: stated=%s computed=%s", stated, computed) } return nil } // isHexDigit reports whether c is a base-16 digit in either case. §6.1 // defines the Z payload's grammar as md5-token = 32hexdig-ci, a // parse-accept grammar that is explicitly case-insensitive, so rejecting // uppercase would refuse artifacts canonical fossil accepts. The // case-insensitivity is structural only: §6.2 still requires an uppercase // token to fail verification, which VerifyZ enforces by comparing // byte-exact against the lowercase computeZ. func isHexDigit(c byte) bool { switch { case c >= '0' && c <= '9': return true case c >= 'a' && c <= 'f': return true case c >= 'A' && c <= 'F': return true default: return false } } func computeZ(data []byte) string { h := md5.Sum(data) return hex.EncodeToString(h[:]) }