// 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 'S':
// draft-fossil-submanifests §4.1: exactly a name and a raw hash,
// strictly ascending by decoded name.
if err := arity(2, 2); err != nil {
return err
}
f := File{Name: decode(tok[0])}
if !validPath(f.Name) {
return fail("bad filename " + f.Name)
}
if !validHash(tok[1]) {
return fail("bad hash")
}
f.Hash = tok[1]
if len(p.a.SubManifests) > 0 && f.Name <= p.lastSub {
return fail("filenames not strictly ascending")
}
p.lastSub = f.Name
p.a.SubManifests = append(p.a.SubManifests, 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
}
// §4.7.17 is a MUST: a U card that is PRESENT but carries an
// empty or omitted login — a bare U, or a U followed by an empty
// token — reads as the literal "anonymous". Leaving it empty
// attributes the artifact to nobody, which is a different claim
// from fossil's: fossil says an anonymous person did this.
//
// Absence of the card entirely is a separate matter, governed by
// the per-type required-card rule of §5.2.
p.a.User = "anonymous"
if len(tok) == 1 && tok[0] != "" {
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)
}