// Package artifact parses Fossil structural artifacts per the published
// artifact-format RFC (draft-fossil-artifact-format-00; §-cites below
// refer to it). This is quarry's half of the 0001 §2 contract: zit
// transports whole artifacts out of its private container, quarry
// interprets them against the public spec.
//
// Parse returns (nil, nil) for content that fails the framing gates —
// an ordinary blob, not an error. It returns a non-nil error only for
// input that passed framing (so was plausibly intended as structure)
// but violates the card grammar; ingest may log those, but stores treat
// them as content too.
package artifact
import (
"bytes"
"crypto/md5"
"encoding/hex"
"fmt"
"strings"
)
// Type is the artifact classification (§5.1).
type Type int
const (
Manifest Type = iota + 1 // "checkin"
Cluster // "cluster"
Control // "tag"
Wiki // "wiki"
Ticket // "ticket"
Attachment // "attachment"
Technote // "technote"
Forum // "forumpost"
)
func (t Type) String() string {
names := [...]string{"", "checkin", "cluster", "tag", "wiki", "ticket",
"attachment", "technote", "forumpost"}
if int(t) < len(names) {
return names[t]
}
return "unknown"
}
// File is one F card (§4.7.6). An empty Hash in a delta manifest marks
// deletion.
type File struct {
Name string
Hash string
Perm string
OldName string
}
// Tag is one T card (§4.7.16). Name keeps its sign prefix (+, -, *);
// Target is a hash or "*" (self).
type Tag struct {
Name string
Target string
Value string
}
// Field is one J card (§4.7.8).
type Field struct {
Name string
Value string
}
// Attach is the A card (§4.7.2).
type Attach struct {
Name string
Target string
Source string
}
// Cherrypick is one Q card (§4.7.14). Sign is '+' or '-'.
type Cherrypick struct {
Sign byte
Hash string
}
// Artifact is one parsed structural artifact.
type Artifact struct {
Type Type
Comment string // C
Date string // D, raw canonical token
User string // U ("anonymous" when empty per §4.7.17)
Mimetype string // N
Baseline string // B
Files []File // F
Parents []string // P
Tags []Tag // T
Cherrypicks []Cherrypick // Q
RCard string // R (structurally validated only, §6.3.1)
TicketID string // K
Fields []Field // J
WikiTitle string // L
Body []byte // W
EventTime string // E token 1
EventID string // E token 2
ThreadRoot string // G
InReplyTo string // I
ThreadTitle string // H
Attachment *Attach // A
Members []string // M
}
// DecodeToken applies the §4.3 card-token escape decoding — exported
// for consumers of streams that carry card-escaped tokens outside an
// artifact body (zit crosslink, #109).
func DecodeToken(s string) string { return decode(s) }
// Parse classifies and parses raw stored bytes. (nil, nil) means the
// input is an opaque content blob.
func Parse(raw []byte) (*Artifact, error) {
body, ok := frame(raw)
if !ok {
return nil, nil
}
p := &parser{rest: body}
a, err := p.parse()
if err != nil {
return nil, err
}
return a, nil
}
// frame runs the §4.1 gates: LF-terminated, signature wrapper stripped,
// line-1 shape, Z self-checksum. A failed gate means "content".
func frame(raw []byte) ([]byte, bool) {
if len(raw) == 0 || raw[len(raw)-1] != '\n' { // gate 0, raw bytes
return nil, false
}
body := stripSignature(raw)
// Gate 2 (§4.1 step 3): stripped content ≥10 bytes, first byte A-Z,
// second byte exactly one space (argument-less first card rejected).
if len(body) < 10 || body[0] < 'A' || body[0] > 'Z' || body[1] != ' ' {
return nil, false
}
// Gate 3: Z self-checksum — verify when present, pass when absent
// (absence is caught by the per-type required-card rule, §5.2).
if n := len(body); n >= 35 && body[n-35] == 'Z' && body[n-34] == ' ' {
sum := md5.Sum(body[:n-35])
if hex.EncodeToString(sum[:]) != string(body[n-33:n-1]) {
return nil, false
}
}
return body, true
}
// stripSignature removes a PGP/SSH clearsign wrapper (§4.1.1).
func stripSignature(raw []byte) []byte {
if !bytes.HasPrefix(raw, []byte("-----BEGIN PGP SIGNED MESSAGE-----")) &&
!bytes.HasPrefix(raw, []byte("-----BEGIN SSH SIGNED MESSAGE-----")) {
return raw
}
start := raw
if i := bytes.Index(raw, []byte("\n\n")); i >= 0 {
start = raw[i+2:]
} else if i := bytes.Index(raw, []byte("\n\r\n")); i >= 0 {
start = raw[i+3:]
} else {
return raw // no blank line: wholly unstripped (§4.1.1)
}
// Backward truncate at the signature trailer, keeping its lead LF.
for _, marker := range []string{"\n-----BEGIN PGP SIGNATURE----", "\n-----BEGIN SSH SIGNATURE----"} {
if i := bytes.LastIndex(start, []byte(marker)); i >= 0 {
return start[:i+1]
}
}
return start
}
// singleOccurrence lists the cards a second copy of which rejects (§4.5.1).
const singleOccurrence = "ABCDEGHIKLNRUW"
// typeOf maps type-setting card letters (§5.3). Zero = not type-setting.
var typeOf = map[byte]Type{
'A': Attachment, 'B': Manifest, 'F': Manifest, 'Q': Manifest,
'R': Manifest, 'E': Technote, 'G': Forum, 'H': Forum, 'I': Forum,
'J': Ticket, 'K': Ticket, 'L': Wiki, 'M': Cluster,
}
// legality is the §5.2 decision table: allowed and required card sets.
var legality = map[Type]struct{ allowed, required string }{
Manifest: {"BCDFNPQRTUZ", "DZ"},
Cluster: {"MZ", "MZ"},
Control: {"DTUZ", "DTUZ"},
Wiki: {"CDLNPUWZ", "DLUWZ"},
Ticket: {"DJKUZ", "DJKUZ"},
Attachment: {"ACDNUZ", "ADZ"},
Technote: {"CDENPTUWZ", "DEWZ"},
Forum: {"DGHINPUWZ", "DUWZ"},
}
type parser struct {
rest []byte
a Artifact
seen [26]bool // per-letter presence
prev byte // last card letter, for §4.4 ordering
// last raw sort keys for the repeatable-card intra-run checks (§4.5.2)
lastFile, lastField, lastMember string
lastTagName, lastTagTarget string
}
func (p *parser) parse() (*Artifact, error) {
for len(p.rest) > 0 {
line, rest, _ := bytes.Cut(p.rest, []byte{'\n'})
p.rest = rest
if err := p.card(line); err != nil {
return nil, err
}
}
return p.finish()
}
func (p *parser) card(line []byte) error {
if len(line) == 0 || line[0] < 'A' || line[0] > 'Z' {
return fmt.Errorf("artifact: bad card line %q", line)
}
letter := line[0]
if strings.IndexByte("ABCDEFGHIJKLMNPQRTUWZ", letter) < 0 {
return fmt.Errorf("artifact: undefined card %c (§4.2.2)", letter)
}
var tokens []string
switch {
case len(line) == 1: // argument-less
case line[1] != ' ':
return fmt.Errorf("artifact: card %c: byte after letter must be space", letter)
default:
// Real Fossil writes an empty VALUE as a bare trailing space:
// "C ", "J comment ", "T +date <hash> ". §4.2's token = 1*tchar
// forbids that everywhere, and §4.7.17 grants it only for the U
// card — but the rule is positional, not per-card: fossil's
// next_token() returns a non-NULL empty string whenever the card
// ends in a space, on any letter, and each card's own validation
// then decides. Rejecting it costs a repository its tickets,
// tags and empty-comment check-ins, because the artifact fails
// to parse whole and falls back to opaque content.
//
// So an empty token is legal in FINAL position only; each card's
// arity and validation still gate what that means ("C hi " is
// two tokens against a one-token card, and stays rejected).
parts := bytes.Split(line[2:], []byte{' '})
for i, tok := range parts {
if len(tok) == 0 && i != len(parts)-1 {
return fmt.Errorf("artifact: card %c: empty token", letter)
}
tokens = append(tokens, string(tok))
}
}
// Ordering (§4.4): non-decreasing, with the technote N-after-P
// exception.
if letter < p.prev && !(letter == 'N' && p.prev == 'P' && p.seen['E'-'A']) {
return fmt.Errorf("artifact: card %c out of order after %c", letter, p.prev)
}
p.prev = letter
// Occurrence class (§4.5.1).
if strings.IndexByte(singleOccurrence, letter) >= 0 && p.seen[letter-'A'] {
return fmt.Errorf("artifact: duplicate %c card", letter)
}
p.seen[letter-'A'] = true
return p.apply(letter, tokens)
}
// finish resolves type and runs the post-classification checks.
func (p *parser) finish() (*Artifact, error) {
// Type: last-processed type-setting card wins (§5.3); the ordering
// rule makes that the alphabetically latest one, so a forward scan
// that overwrites is exactly right — apply() did that. Fallback:
if p.a.Type == 0 {
if p.seen['C'-'A'] || len(p.a.Files) > 0 || p.seen['P'-'A'] {
p.a.Type = Manifest
} else {
p.a.Type = Control
}
}
rule := legality[p.a.Type]
for l := byte('A'); l <= 'Z'; l++ {
if p.seen[l-'A'] && strings.IndexByte(rule.allowed, l) < 0 {
return nil, fmt.Errorf("artifact: card %c not allowed in %s", l, p.a.Type)
}
}
for i := 0; i < len(rule.required); i++ {
if !p.seen[rule.required[i]-'A'] {
return nil, fmt.Errorf("artifact: %s missing required %c card", p.a.Type, rule.required[i])
}
}
// Per-type semantic checks (§5.4).
switch p.a.Type {
case Control:
for _, t := range p.a.Tags {
if t.Target == "*" {
return nil, fmt.Errorf("artifact: control artifact with self-referential tag")
}
}
case Technote:
for _, t := range p.a.Tags {
if t.Target != "*" || !strings.HasPrefix(t.Name, "+") {
return nil, fmt.Errorf("artifact: technote tag must be self-referential +tag")
}
}
case Forum:
if p.seen['H'-'A'] && p.seen['I'-'A'] {
return nil, fmt.Errorf("artifact: forum post with both H and I cards")
}
if len(p.a.Parents) > 1 {
return nil, fmt.Errorf("artifact: forum post with merge parents")
}
}
if p.a.User == "" {
p.a.User = "anonymous" // §4.7.17
}
return &p.a, nil
}