Skip to content
quarry Artifact [8ef42700e8]
Login

Artifact [8ef42700e8]

Artifact 8ef42700e8ae72e263a16be3094c5aff8456c8a5b45ac162dc22e28f8878e0cf:


// cards.go — per-card grammars (§4.7), escape decoding (§4.3), and the
// validity predicates (§4.6). Decoded token positions follow the §4.3.1
// table exactly; hash tokens follow the §4.7.1 validation map.
package artifact

import (
	"fmt"
	"strconv"
	"strings"
)

// apply validates one card and folds it into the artifact. tokens are
// raw (wire) form; decoding happens per position here.
func (p *parser) apply(letter byte, tok []string) error {
	fail := func(msg string) error { return fmt.Errorf("artifact: %c card: %s", letter, msg) }
	arity := func(min, max int) error {
		if len(tok) < min || len(tok) > max {
			return fail(fmt.Sprintf("want %d..%d tokens, got %d", min, max, len(tok)))
		}
		return nil
	}
	if t, ok := typeOf[letter]; ok {
		p.a.Type = t // last one processed wins (§5.3)
	}

	switch letter {
	case 'A':
		if err := arity(2, 3); err != nil {
			return err
		}
		at := Attach{Name: decode(tok[0]), Target: decode(tok[1])}
		if !validPath(at.Name) {
			return fail("bad filename")
		}
		// Target is a hash or a wiki name, validated post-decode (§4.7.2).
		if !validHash(at.Target) && !validWikiName(at.Target) {
			return fail("target is neither hash nor wiki name")
		}
		if len(tok) == 3 {
			if !validHash(tok[2]) {
				return fail("bad source hash")
			}
			at.Source = tok[2]
		}
		p.a.Attachment = &at
	case 'B':
		if err := arity(1, 1); err != nil {
			return err
		}
		if !validHash(tok[0]) {
			return fail("bad hash")
		}
		p.a.Baseline = tok[0]
	case 'C':
		if err := arity(1, 1); err != nil {
			return err
		}
		p.a.Comment = decode(tok[0])
	case 'D', 'E':
		max := 1
		if letter == 'E' {
			max = 2
		}
		if err := arity(max, max); err != nil {
			return err
		}
		if !validTimestamp(tok[0]) {
			return fail("bad timestamp " + tok[0])
		}
		if letter == 'D' {
			p.a.Date = tok[0]
		} else {
			if !validHash(tok[1]) {
				return fail("bad technote id")
			}
			p.a.EventTime, p.a.EventID = tok[0], tok[1]
		}
	case 'F':
		if err := arity(1, 4); err != nil {
			return err
		}
		f := File{Name: decode(tok[0])}
		if !validPath(f.Name) {
			return fail("bad filename " + f.Name)
		}
		if len(tok) > 1 {
			if !validHash(tok[1]) {
				return fail("bad hash")
			}
			f.Hash = tok[1]
		} else if !p.seen['B'-'A'] {
			return fail("hash-less entry outside a delta manifest (§4.7.6)")
		}
		if len(tok) > 2 {
			f.Perm = tok[2] // parsed raw, never validated (§4.7.6)
		}
		if len(tok) > 3 {
			f.OldName = decode(tok[3])
			if !validPath(f.OldName) {
				return fail("bad old-name")
			}
		}
		// Strictly ascending by decoded filename (§4.5.2).
		if len(p.a.Files) > 0 && f.Name <= p.lastFile {
			return fail("filenames not strictly ascending")
		}
		p.lastFile = f.Name
		p.a.Files = append(p.a.Files, f)
	case 'G', 'I':
		if err := arity(1, 1); err != nil {
			return err
		}
		if !validHash(tok[0]) {
			return fail("bad hash")
		}
		if letter == 'G' {
			p.a.ThreadRoot = tok[0]
		} else {
			p.a.InReplyTo = tok[0]
		}
	case 'H':
		if err := arity(1, 1); err != nil {
			return err
		}
		p.a.ThreadTitle = decode(tok[0])
	case 'J':
		if err := arity(1, 2); err != nil {
			return err
		}
		f := Field{Name: tok[0]} // field name NOT decoded (§4.3.1)
		if len(f.Name) == 0 {
			return fail("empty field name")
		}
		if len(tok) == 2 {
			f.Value = decode(tok[1])
		}
		if len(p.a.Fields) > 0 && f.Name <= p.lastField {
			return fail("field names not strictly ascending")
		}
		p.lastField = f.Name
		p.a.Fields = append(p.a.Fields, f)
	case 'K':
		if err := arity(1, 1); err != nil {
			return err
		}
		if !hexToken(tok[0], 40) { // always 40, never 64 (§4.7.9)
			return fail("ticket id must be 40 hex chars")
		}
		p.a.TicketID = tok[0]
	case 'L':
		if err := arity(1, 1); err != nil {
			return err
		}
		p.a.WikiTitle = decode(tok[0])
		if !validWikiName(p.a.WikiTitle) {
			return fail("bad wiki title")
		}
	case 'M':
		if err := arity(1, 1); err != nil {
			return err
		}
		if !validHash(tok[0]) {
			return fail("bad hash")
		}
		if len(p.a.Members) > 0 && tok[0] <= p.lastMember {
			return fail("members not strictly ascending")
		}
		p.lastMember = tok[0]
		p.a.Members = append(p.a.Members, tok[0])
	case 'N':
		if err := arity(1, 1); err != nil {
			return err
		}
		p.a.Mimetype = decode(tok[0])
	case 'P':
		for _, h := range tok { // zero tokens allowed (§4.7.13)
			if !validHash(h) {
				return fail("bad parent hash")
			}
			p.a.Parents = append(p.a.Parents, h)
		}
	case 'Q':
		if err := arity(1, 2); err != nil {
			return err
		}
		if len(tok[0]) < 2 || (tok[0][0] != '+' && tok[0][0] != '-') || !validHash(tok[0][1:]) {
			return fail("bad cherry-pick token")
		}
		if len(tok) == 2 && !validHash(tok[1]) {
			return fail("bad baseline hash")
		}
		p.a.Cherrypicks = append(p.a.Cherrypicks, Cherrypick{Sign: tok[0][0], Hash: tok[0][1:]})
	case 'R':
		if err := arity(1, 1); err != nil {
			return err
		}
		if !hexToken(tok[0], 32) {
			return fail("checksum must be 32 hex chars")
		}
		p.a.RCard = tok[0] // structural check only; never recomputed here (§6.3.1)
	case 'T':
		if err := arity(2, 3); err != nil {
			return err
		}
		t := Tag{Name: decode(tok[0]), Target: tok[1]}
		if len(t.Name) < 2 || strings.IndexByte("+-*", t.Name[0]) < 0 {
			return fail("tag needs sign prefix")
		}
		if t.Target != "*" && !validHash(t.Target) {
			return fail("bad target")
		}
		if len(tok) == 3 {
			t.Value = decode(tok[2])
		}
		// Two-level ascending: (name incl. sign, then target) (§4.5.2).
		if len(p.a.Tags) > 0 {
			if t.Name < p.lastTagName || (t.Name == p.lastTagName && t.Target <= p.lastTagTarget) {
				return fail("tags not in canonical order")
			}
		}
		p.lastTagName, p.lastTagTarget = t.Name, t.Target
		p.a.Tags = append(p.a.Tags, t)
	case 'U':
		if err := arity(0, 1); err != nil {
			return err
		}
		if len(tok) == 1 {
			p.a.User = decode(tok[0])
		}
	case 'W':
		if err := arity(1, 1); err != nil {
			return err
		}
		size, err := strconv.Atoi(tok[0])
		if err != nil || size < 0 {
			return fail("bad size")
		}
		// The body is the next size raw bytes of the artifact, then LF
		// (§4.7.18) — the one place a card spans lines.
		if len(p.rest) < size+1 || p.rest[size] != '\n' {
			return fail("body shorter than declared size")
		}
		p.a.Body = append([]byte(nil), p.rest[:size]...)
		p.rest = p.rest[size+1:]
	case 'Z':
		if err := arity(1, 1); err != nil {
			return err
		}
		if !hexToken(tok[0], 32) {
			return fail("checksum must be 32 hex chars")
		}
		// Verified against bytes in frame(); nothing to record.
	}
	return nil
}

// decode applies the §4.3 escape decoding.
func decode(s string) string {
	if !strings.ContainsRune(s, '\\') {
		return s
	}
	var b strings.Builder
	for i := 0; i < len(s); i++ {
		if s[i] != '\\' || i == len(s)-1 {
			b.WriteByte(s[i]) // literal, or trailing backslash kept as-is
			continue
		}
		i++
		switch s[i] {
		case 'n':
			b.WriteByte('\n')
		case 's':
			b.WriteByte(' ')
		case 't':
			b.WriteByte('\t')
		case 'r':
			b.WriteByte('\r')
		case 'v':
			b.WriteByte('\v')
		case 'f':
			b.WriteByte('\f')
		case '0':
			b.WriteByte(0)
		case '\\':
			b.WriteByte('\\')
		default:
			b.WriteByte(s[i]) // unrecognized: drop backslash, keep byte
		}
	}
	return b.String()
}

// hexToken reports whether s is exactly n case-insensitive hex chars.
func hexToken(s string, n int) bool {
	if len(s) != n {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !('0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F') {
			return false
		}
	}
	return true
}

// validHash accepts 40- or 64-char hex artifact names (§4.7.1).
func validHash(s string) bool {
	return hexToken(s, 40) || hexToken(s, 64)
}

// validPath is the simple-pathname predicate (§4.6.1).
func validPath(s string) bool {
	if s == "" || s == "." || s == ".." ||
		strings.HasPrefix(s, "/") || strings.HasPrefix(s, "./") ||
		strings.HasPrefix(s, "../") || strings.HasSuffix(s, "/") ||
		strings.Contains(s, "//") {
		return false
	}
	for _, el := range strings.Split(s, "/") {
		if el == "." || el == ".." {
			return false
		}
	}
	return true
}

// validWikiName is the wiki-name predicate (§4.6.2).
func validWikiName(s string) bool {
	if len(s) == 0 || len(s) > 100 || s[0] <= 0x20 || s[len(s)-1] == ' ' ||
		strings.Contains(s, "  ") {
		return false
	}
	for i := 1; i < len(s); i++ {
		if s[i] < 0x20 {
			return false
		}
	}
	return true
}

// validTimestamp accepts the canonical date-time form plus the date-only
// and time-only compat forms (§4.6.4 items 1–3). The `now`/Julian-day
// producer conveniences (items 4–5) are deliberately not accepted: zit
// never writes them, and a reader only MAY.
func validTimestamp(s string) bool {
	neg := strings.HasPrefix(s, "-")
	body := strings.TrimPrefix(s, "-")
	if !neg && timeOnly(body) {
		return true
	}
	if len(body) < 10 || !dateOK(body[:10], neg) {
		return false
	}
	rest := body[10:]
	if rest == "" {
		return true // date only
	}
	// 1+ separator bytes: whitespace-class or 'T'.
	i := 0
	for i < len(rest) && (rest[i] == 'T' || rest[i] == ' ' || rest[i] == '\t') {
		i++
	}
	if i == 0 {
		return false
	}
	return timeWithZone(rest[i:])
}

func digits(s string) bool {
	for i := 0; i < len(s); i++ {
		if s[i] < '0' || s[i] > '9' {
			return false
		}
	}
	return len(s) > 0
}

func inRange(s string, lo, hi int) bool {
	if len(s) != 2 || !digits(s) {
		return false
	}
	n, _ := strconv.Atoi(s)
	return n >= lo && n <= hi
}

func dateOK(s string, neg bool) bool {
	if len(s) != 10 || s[4] != '-' || s[7] != '-' ||
		!digits(s[:4]) || !inRange(s[5:7], 1, 12) || !inRange(s[8:10], 1, 31) {
		return false
	}
	// Range floor: strictly after -4713-11-24 (§4.6.4).
	if neg {
		y, _ := strconv.Atoi(s[:4])
		m, _ := strconv.Atoi(s[5:7])
		d, _ := strconv.Atoi(s[8:10])
		if y > 4713 || (y == 4713 && (m > 11 || (m == 11 && d >= 24))) {
			return false
		}
	}
	return true
}

func timeOnly(s string) bool {
	// HH:MM [":" SS ["." 1*DIGIT]]
	if len(s) < 5 || s[2] != ':' || !inRange(s[:2], 0, 24) || !inRange(s[3:5], 0, 59) {
		return false
	}
	rest := s[5:]
	if rest == "" {
		return true
	}
	if rest[0] != ':' || len(rest) < 3 || !inRange(rest[1:3], 0, 59) {
		return false
	}
	rest = rest[3:]
	return rest == "" || (rest[0] == '.' && digits(rest[1:]))
}

func timeWithZone(s string) bool {
	// Split an optional trailing zone: Z, z, or ±HH:MM, optionally
	// preceded by whitespace.
	if strings.HasSuffix(s, "Z") || strings.HasSuffix(s, "z") {
		return timeOnly(strings.TrimRight(s[:len(s)-1], " \t"))
	}
	if len(s) > 6 && (s[len(s)-6] == '+' || s[len(s)-6] == '-') &&
		inRange(s[len(s)-5:len(s)-3], 0, 24) && s[len(s)-3] == ':' &&
		inRange(s[len(s)-2:], 0, 59) {
		return timeOnly(strings.TrimRight(s[:len(s)-6], " \t"))
	}
	return timeOnly(s)
}