Artifact a75e50256293e4df1a2550078effe7dba99ed43f9cf92a717c575b516cb54e71:
- File
internal/deck/clearsign.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: 3828) [annotate] [blame] [check-ins using]
package deck import "bytes" // A control artifact blob may be PGP- or SSH-clearsigned: the manifest text // is wrapped in an armored header, a blank line, then the cards, and finally a // detached signature block. Fossil's own src/manifest.c (remove_pgp_signature) // strips this framing before verifying the Z-card checksum and parsing cards, // so the checksum is computed over the inner content only. These are the exact // marker strings it matches. var ( clearsignHeaders = [][]byte{ []byte("-----BEGIN PGP SIGNED MESSAGE-----"), []byte("-----BEGIN SSH SIGNED MESSAGE-----"), } // fossil's remove_pgp_signature matches the signature trailer with // strncmp(..., 29): the LF plus the first 28 bytes of the armor line, one // short of its final dash. So a trailing dash mangled to another byte // ("-----BEGIN PGP SIGNATURE----x") is still recognized and stripped. // These markers are those 29 bytes, so bytes.HasPrefix matches the same. clearsignSignatures = [][]byte{ []byte("\n-----BEGIN PGP SIGNATURE----"), []byte("\n-----BEGIN SSH SIGNATURE----"), } ) // clearsignHeaderLen is the fixed byte length of every clearsign header marker // above; the armor-header scan begins just past it. All markers share this // length, which the assertion in stripClearsign pins. const clearsignHeaderLen = 34 // stripClearsign returns the inner control-artifact content of a clearsigned // blob -- the bytes between the armor header's blank-line separator and the // trailing signature block -- which is what the Z card is computed over and // what the card parser consumes. It mirrors fossil's remove_pgp_signature // (src/manifest.c): a byte-for-byte identity on non-clearsigned input (the // overwhelmingly common case) and idempotent, since stripped content no longer // begins with a header marker. The artifact's content-addressed hash is taken // over the raw blob elsewhere, so this stripping never changes which UUID an // artifact resolves to. func stripClearsign(data []byte) []byte { header := matchPrefix(data, clearsignHeaders) if header == nil { return data } if len(header) != clearsignHeaderLen { panic("deck.stripClearsign: clearsign header marker length mismatch") } // Skip the armor headers (e.g. "Hash: SHA1") up to and including the blank // line that separates them from the message body. i lands on the first // byte of the inner content. n := len(data) i := clearsignHeaderLen for i < n && !afterBlankLine(data, i) { i++ } if i >= n { return data // No message body found; verifyZ will reject it. } body := data[i:] // Trim the trailing signature block. Scan backward for the '\n' that // begins "\n-----BEGIN PGP SIGNATURE-----", keeping that '\n' so the inner // content still ends in a newline (matching fossil's n = i+1). for j := len(body) - 1; j >= 0; j-- { if body[j] != '\n' { continue } if matchPrefix(body[j:], clearsignSignatures) != nil { return body[:j+1] } } return body // Header but no signature trailer; leave the body as-is. } // afterBlankLine reports whether index i in z sits immediately after a blank // line, i.e. z[i-1] is a '\n' preceded by either another '\n' or a "\r\n". // Mirrors fossil's after_blank_line. i is always >= clearsignHeaderLen at the // call site, so the lookbehinds are in bounds; the guards assert that. func afterBlankLine(z []byte, i int) bool { if i < 1 || z[i-1] != '\n' { return false } if i >= 2 && z[i-2] == '\n' { return true } if i >= 3 && z[i-2] == '\r' && z[i-3] == '\n' { return true } return false } // matchPrefix returns the first marker in markers that is a prefix of data, or // nil if none match. func matchPrefix(data []byte, markers [][]byte) []byte { for _, marker := range markers { if bytes.HasPrefix(data, marker) { return marker } } return nil }