Skip to content
quarry Artifact [440ad03d50]
Login

Artifact [440ad03d50]

Artifact 440ad03d50d3b26fd10a99ac9e89041d069e8380d281284d23802f22e795effb:


// 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
}