Artifact 8ef42700e8ae72e263a16be3094c5aff8456c8a5b45ac162dc22e28f8878e0cf:
- File
internal/artifact/cards.go
— part of check-in
[3129a9afe0]
at
2026-08-15 10:40:01
on branch trunk
— artifact: structural-artifact parser per the artifact-format RFC
Framing gates, escape decoding, card grammar/ordering, type classification, Z verification; corpus testdata from the fixture. (user: dmestas, size: 10770) [annotate] [blame] [check-ins using]
// 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) }