package conformance
import (
"fmt"
"strings"
"github.com/danmestas/go-libfossil/internal/deck"
)
// The hand-written artifact cases, by draft section. IDs match libfossil's
// suite wherever it has the same case.
// Signature wrappers (Section 4.1.1). The signature bytes are opaque.
const (
pgpHead = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n"
pgpSig = "-----BEGIN PGP SIGNATURE-----\n\nc2lnbmF0dXJl\n-----END PGP SIGNATURE-----\n"
sshHead = "-----BEGIN SSH SIGNED MESSAGE-----\nHash: SHA256\n\n"
sshSig = "-----BEGIN SSH SIGNATURE-----\nc2lnbmF0dXJl\n-----END SSH SIGNATURE-----\n"
)
// signedControl is the control base with its Z card, as a signed artifact
// wraps it.
var signedControl = string(withZ(baseControl))
// raw builds a case whose input is used as given, without a Z card added.
func raw(id, req, input string, accept bool) artifactCase {
return artifactCase{Case: Case{ID: id, Req: req}, input: []byte(input), accept: accept}
}
// says sets the substring fossil's rejection reason must contain.
func says(c artifactCase, reason string) artifactCase {
c.fossilSays = reason
return c
}
// zRejected builds a rejection the Z card itself is meant to cause, so the
// input need not pass deck.VerifyZ.
func zRejected(id, req, input string) artifactCase {
c := raw(id, req, input, false)
c.zCase = true
return c
}
// signedCase builds an accepted signed input; Marshal writes only the
// inner content, so none of them re-encode.
func signedCase(id, req, input string) artifactCase {
c := raw(id, req, input, true)
c.nonCanonical = true
c.check = wantType(deck.Control)
return c
}
var framingCases = []artifactCase{
raw("artifact.4.1.empty", "AF-4.1-1", "", false),
raw("artifact.4.1.no-final-lf", "AF-4.1-1", strings.TrimSuffix(signedControl, "\n"), false),
raw("artifact.4.1.1.outer-no-lf", "AF-4.1-1",
pgpHead+signedControl+strings.TrimSuffix(pgpSig, "\n"), false),
raw("artifact.4.1.short", "AF-4.1-2", "D 2026\n", false),
rejected("artifact.4.1.lowercase", "AF-4.1-2", edit(baseManifest, "C base", "c base")),
rejected("artifact.4.1.first-bare", "AF-4.1-2",
edit(baseManifest, "C base\\smanifest\n", "C\n")),
rejected("artifact.4.1.first-tab", "AF-4.1-2", edit(baseManifest, "C base", "C\tbase")),
rejected("artifact.4.1.leading-space", "AF-4.1-2", " "+baseManifest),
rejected("artifact.4.1.guard", "AF-4.1-2", "# inert fixture\n"+baseManifest),
says(raw("artifact.4.2.residual", "AF-4.1-3", signedControl+"garbage\n", false),
"extra characters at end of card"),
// Section 4.1.2: the marker follows the final card, so the card loop
// reads it as a '#' card and rejects it for card order, after the Z card.
says(raw("artifact.4.1.2.sterilized", "AF-4.1.2-1", string(withZ(baseManifest))+
"# Remove this line to create a well-formed Fossil manifest.\n", false),
"cards not in lexicographical order"),
says(raw("artifact.4.1.2.signed-guard", "AF-4.1.2-1", pgpHead+signedControl+
"# Remove this line to create a well-formed Fossil control artifact.\n"+pgpSig, false),
"cards not in lexicographical order"),
}
var signatureCases = []artifactCase{
signedCase("artifact.4.1.1.pgp", "AF-4.1.1-1", pgpHead+signedControl+pgpSig),
signedCase("artifact.4.1.1.ssh", "AF-4.1.1-2", sshHead+signedControl+sshSig),
raw("artifact.4.1.1.leading-space", "AF-4.1.1-3", " "+pgpHead+signedControl+pgpSig, false),
// One dash short: the first 34 bytes are no longer a header line.
raw("artifact.4.1.1.header-short", "AF-4.1.1-3",
"-----BEGIN PGP SIGNED MESSAGE----\n\n"+signedControl+pgpSig, false),
signedCase("artifact.4.1.1.pgp-crlf", "AF-4.1.1-4",
strings.Replace(pgpHead, "\n\n", "\n\r\n", 1)+signedControl+pgpSig),
raw("artifact.4.1.1.missing-blank", "AF-4.1.1-4",
strings.Replace(pgpHead, "\n\n", "\n", 1)+signedControl+pgpSig, false),
signedCase("artifact.4.1.1.pgp-no-footer", "AF-4.1.1-5", pgpHead+signedControl),
// The cut is at the last armor line: a W body holding one is content.
{
Case: Case{ID: "artifact.4.1.1.armor-in-body", Req: "AF-4.1.1-5"},
input: []byte(pgpHead + string(withZ(edit(baseWiki, "W 12\nhello\nworld\n\n",
"W 32\nx\n-----BEGIN PGP SIGNATURE-----\n\n"))) + pgpSig),
accept: true, nonCanonical: true,
check: func(d *deck.Deck) error {
return want("W", string(d.W), "x\n-----BEGIN PGP SIGNATURE-----\n")
},
},
signedCase("artifact.4.1.1.ssh-short", "AF-4.1.1-6",
sshHead+signedControl+"-----BEGIN SSH SIGNATURE----x\nc2lnbmF0dXJl\n"),
signedCase("artifact.4.1.1.pgp-short", "AF-4.1.1-6",
pgpHead+signedControl+"-----BEGIN PGP SIGNATURE----x\nc2lnbmF0dXJl\n"),
// The Z card covers the stripped content only, never the wrapper.
zRejected("artifact.6.2.signed-z-outer", "AF-6.2-2",
pgpHead+baseControl+zCard(pgpHead+baseControl)+pgpSig),
}
var grammarCases = []artifactCase{
says(rejected("artifact.4.2.card-tab", "AF-4.2-1", edit(baseManifest, "D 2026", "D\t2026")),
"extra characters at end of card"),
says(rejected("artifact.4.2.card-cr", "AF-4.2-1", edit(baseManifest, "U alice", "U\ralice")),
"extra characters at end of card"),
loose("artifact.4.2.token-tab", "AF-4.2-2",
edit(baseManifest, "C base\\smanifest", "C base\tmanifest"),
func(d *deck.Deck) error { return want("C", d.C, "base\tmanifest") }),
loose("artifact.4.2.token-cr", "AF-4.2-2",
edit(baseManifest, "C base\\smanifest", "C base\rmanifest"),
func(d *deck.Deck) error { return want("C", d.C, "base\rmanifest") }),
loose("artifact.4.2.file-tab", "AF-4.2-2", edit(baseManifest, "F a.txt ", "F a\tb.txt "),
func(d *deck.Deck) error {
return want("F[0]", d.F[0].Name+" "+d.F[0].UUID, "a\tb.txt "+h1)
}),
rejected("artifact.4.2.hash-cr", "AF-4.2-2", edit(baseDelta, "B "+h1+"\n", "B "+h1+"\r\n")),
loose("artifact.4.2.login-cr", "AF-4.2-2", edit(baseManifest, "U alice\n", "U alice\r\n"),
func(d *deck.Deck) error { return want("U", *d.U, "alice\r") }),
rejected("artifact.4.2.double-space", "AF-4.2-3",
edit(baseManifest, "F a.txt "+h1, "F a.txt "+h1)),
// Section 4.2: a line ending in a space carries a present, empty last
// token, which the C, J, N and T cards keep as an empty value.
accepted("artifact.4.7.c-empty", "AF-4.7.4-2",
edit(baseManifest, "C base\\smanifest\n", "C \n"),
func(d *deck.Deck) error { return want("C", d.C, "") }),
loose("artifact.4.7.j-empty-value", "AF-4.2-3",
edit(baseTicket, "J status Open\n", "J status \n"),
func(d *deck.Deck) error { return want("J[1]", d.J[1], deck.TicketField{Name: "status"}) }),
accepted("artifact.4.7.n-empty", "AF-4.2-3", edit(baseWiki, "N text/x-markdown\n", "N \n"),
func(d *deck.Deck) error { return want("N", d.N, "") }),
loose("artifact.4.7.f-empty-permission", "AF-4.2-3",
edit(baseManifest, "F a.txt "+h1+"\n", "F a.txt "+h1+" \n"),
func(d *deck.Deck) error { return want("F[0].Perm", d.F[0].Perm, "") }),
accepted("artifact.4.7.16.t-empty-value", "AF-4.7.16-4", edit(baseControl, " blue\n", " \n"),
func(d *deck.Deck) error { return want("T[0].Value", d.T[0].Value, "") }),
rejected("artifact.4.2.2.unknown-O", "AF-4.2.2-2",
edit(baseManifest, "P "+h4, "O unknown\nP "+h4)),
rejected("artifact.4.2.2.unknown-S", "AF-4.2.2-2",
edit(baseManifest, "T *branch", "S unknown\nT *branch")),
rejected("artifact.4.2.2.unknown-V", "AF-4.2.2-2", baseManifest+"V unknown\n"),
rejected("artifact.4.2.2.unknown-X", "AF-4.2.2-2", baseManifest+"X unknown\n"),
rejected("artifact.4.2.2.unknown-Y", "AF-4.2.2-2", baseManifest+"Y unknown\n"),
rejected("artifact.4.2.2.unknown-a", "AF-4.2.2-1", baseManifest+"a unknown\n"),
// '1' sorts below every letter, so its only ordered place is first,
// where the line-1 gate is what rejects it.
says(rejected("artifact.4.2.2.unknown-1", "AF-4.1-2", "1 unknown\n"+baseManifest),
"line 1 not recognized"),
// 0x7F sorts above U whether bytes are compared signed or unsigned;
// fossil compares them signed, so a byte from 0x80 up sorts first.
rejected("artifact.4.2.2.unknown-del", "AF-4.2.2-1", baseManifest+"\x7f unknown\n"),
// An empty line is a card whose letter is LF.
// fossil reports the empty line against the card before it.
says(rejected("artifact.4.2.2.blank-line", "AF-4.2.2-1",
edit(baseManifest, "U alice\n", "\nU alice\n")),
"extra characters at end of card"),
}
// cardIs checks one string field against its decoded value.
func cardIs(field string, get func(*deck.Deck) string, value string) func(*deck.Deck) error {
return func(d *deck.Deck) error { return want(field, get(d), value) }
}
var escapeCases = []artifactCase{
accepted("artifact.4.3.escape-pairs", "AF-4.3-1",
edit(baseManifest, "C base\\smanifest", `C a\sb\nc\td\re\vf\fg\\h\0i`),
cardIs("C", func(d *deck.Deck) string { return d.C }, "a b\nc\td\re\vf\fg\\h\x00i")),
loose("artifact.4.3.unknown-escape", "AF-4.3-2",
edit(baseManifest, "C base\\smanifest", `C a\qb`),
cardIs("C", func(d *deck.Deck) string { return d.C }, "aqb")),
loose("artifact.4.3.trailing-backslash", "AF-4.3-2",
edit(baseManifest, "C base\\smanifest", `C trail\`),
cardIs("C", func(d *deck.Deck) string { return d.C }, `trail\`)),
accepted("artifact.4.3.1.a-filename", "AF-4.3.1-1",
edit(baseAttachment, "A report.txt", `A my\sreport.txt`),
cardIs("A.Filename", func(d *deck.Deck) string { return d.A.Filename }, "my report.txt")),
accepted("artifact.4.3.1.a-target", "AF-4.3.1-1",
edit(baseAttachment, "A report.txt "+h1, `A report.txt Home\sPage`),
cardIs("A.Target", func(d *deck.Deck) string { return d.A.Target }, "Home Page")),
accepted("artifact.4.3.1.c-comment", "AF-4.3.1-1", baseManifest,
cardIs("C", func(d *deck.Deck) string { return d.C }, "base manifest")),
accepted("artifact.4.3.1.f-filename", "AF-4.3.1-1",
edit(baseManifest, "F a.txt ", `F a\sz.txt `),
cardIs("F[0].Name", func(d *deck.Deck) string { return d.F[0].Name }, "a z.txt")),
accepted("artifact.4.3.1.f-old-name", "AF-4.3.1-1",
edit(baseManifest, " w old.txt", ` w old\sname.txt`),
cardIs("F[2].OldName", func(d *deck.Deck) string { return d.F[2].OldName },
"old name.txt")),
accepted("artifact.4.3.1.h-title", "AF-4.3.1-1",
edit(baseForum, "I "+h2+"\n", `H a\stitle`+"\n"),
cardIs("H", func(d *deck.Deck) string { return d.H }, "a title")),
accepted("artifact.4.3.1.j-value", "AF-4.3.1-1", baseTicket,
cardIs("J[0].Value", func(d *deck.Deck) string { return d.J[0].Value }, "more text")),
accepted("artifact.4.3.1.l-title", "AF-4.3.1-1", baseWiki,
cardIs("L", func(d *deck.Deck) string { return d.L }, "Home Page")),
accepted("artifact.4.3.1.n-mimetype", "AF-4.3.1-1",
edit(baseWiki, "N text/x-markdown", `N text/x\smarkdown`),
cardIs("N", func(d *deck.Deck) string { return d.N }, "text/x markdown")),
accepted("artifact.4.3.1.t-name", "AF-4.3.1-1",
edit(baseManifest, "T +sym-trunk *", `T +sym-my\strunk *`),
cardIs("T[1].Name", func(d *deck.Deck) string { return d.T[1].Name }, "sym-my trunk")),
accepted("artifact.4.3.1.t-value", "AF-4.3.1-1",
edit(baseManifest, "T *branch * trunk", `T *branch * my\strunk`),
cardIs("T[0].Value", func(d *deck.Deck) string { return d.T[0].Value }, "my trunk")),
accepted("artifact.4.3.1.u-login", "AF-4.3.1-1", edit(baseManifest, "U alice", `U al\sice`),
cardIs("U", func(d *deck.Deck) string { return *d.U }, "al ice")),
accepted("artifact.4.3.1.j-name-raw", "AF-4.3.1-2",
edit(baseTicket, "J status Open", `J sta\stus Open`),
cardIs("J[1].Name", func(d *deck.Deck) string { return d.J[1].Name }, `sta\stus`)),
accepted("artifact.4.3.1.w-body-raw", "AF-4.3.1-2",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 13\nhello\\sworld\n\n"),
cardIs("W", func(d *deck.Deck) string { return string(d.W) }, "hello\\sworld\n")),
rejected("artifact.4.3.1.d-raw", "AF-4.3.1-2",
edit(baseManifest, "D 2026-01-02T", `D 2026-01-02\T`)),
rejected("artifact.4.3.1.hash-raw", "AF-4.3.1-2",
edit(baseManifest, "F a.txt "+h1, `F a.txt 0\`+h1[1:])),
// \1 is no escape, so the token decodes to h1 and is checked as a hash.
loose("artifact.4.3.1.a-target-escaped-hash", "AF-4.3.1-3",
edit(baseAttachment, "A report.txt "+h1, `A report.txt 0\`+h1[1:]),
cardIs("A.Target", func(d *deck.Deck) string { return d.A.Target }, h1)),
}
var orderCases = []artifactCase{
rejected("artifact.4.4.descending", "AF-4.4-1", edit(baseManifest,
"C base\\smanifest\nD "+ts+"\n", "D "+ts+"\nC base\\smanifest\n")),
loose("artifact.4.4.event-n-after-p", "AF-4.4-2", edit(baseEvent,
"N text/x-markdown\nP "+h2+"\n", "P "+h2+"\nN text/x-markdown\n"),
cardIs("N", func(d *deck.Deck) string { return d.N }, "text/x-markdown")),
rejected("artifact.4.4.non-event-n-after-p", "AF-4.4-2", edit(baseWiki,
"N text/x-markdown\nP "+h1+"\n", "P "+h1+"\nN text/x-markdown\n")),
// Appendix C: receivers differ on a repeated P card and the draft does
// not settle it, so fossil(1) decides: it accepts and concatenates.
loose("artifact.4.5.1.p-repeat", "AF-4.5.1-2",
edit(baseManifest, "P "+h4+" "+h5+"\n", "P "+h4+"\nP "+h5+"\n"),
func(d *deck.Deck) error { return want("P", d.P, []string{h4, h5}) }),
loose("artifact.4.5.z-repeat", "AF-4.5.1-3", baseManifest+"Z "+md5Token+"\n", nil),
accepted("artifact.4.5.2.F-ordered", "AF-4.5.2-1", baseManifest,
func(d *deck.Deck) error { return want("len(F)", len(d.F), 3) }),
accepted("artifact.4.5.2.J-ordered", "AF-4.5.2-2", baseTicket,
func(d *deck.Deck) error { return want("len(J)", len(d.J), 2) }),
accepted("artifact.4.5.2.M-ordered", "AF-4.5.2-3", baseCluster,
func(d *deck.Deck) error { return want("M", d.M, []string{h1, h2}) }),
rejected("artifact.4.5.2.F-descending", "AF-4.5.2-1", edit(baseManifest,
"F a.txt "+h1+"\nF b/c.txt "+h2+" x\n", "F b/c.txt "+h2+" x\nF a.txt "+h1+"\n")),
rejected("artifact.4.5.2.F-duplicate", "AF-4.5.2-1",
edit(baseManifest, "F b/c.txt", "F a.txt")),
// Raw order and decoded order disagree here: "a\sz.txt" sorts after
// "a.txt" on the wire but before it once \s is a space.
accepted("artifact.4.5.2.decoded-file-order", "AF-4.5.2-1",
edit(baseManifest, "F a.txt "+h1+"\n", `F a\sz.txt `+h8+"\nF a.txt "+h1+"\n"),
cardIs("F[0].Name", func(d *deck.Deck) string { return d.F[0].Name }, "a z.txt")),
rejected("artifact.4.5.2.decoded-file-descending", "AF-4.5.2-1",
edit(baseManifest, "F a.txt "+h1+"\n", "F a.txt "+h1+"\n"+`F a\sz.txt `+h8+"\n")),
rejected("artifact.4.5.2.J-descending", "AF-4.5.2-2", edit(baseTicket,
"J +comment more\\stext\nJ status Open\n", "J status Open\nJ +comment more\\stext\n")),
rejected("artifact.4.5.2.J-duplicate", "AF-4.5.2-2",
edit(baseTicket, "J status Open", "J +comment Open")),
rejected("artifact.4.5.2.M-descending", "AF-4.5.2-3",
edit(baseCluster, baseCluster, "M "+h2+"\nM "+h1+"\n")),
rejected("artifact.4.5.2.M-duplicate", "AF-4.5.2-3", edit(baseCluster, "M "+h2, "M "+h1)),
rejected("artifact.4.5.2.t-equal", "AF-4.5.2-4",
edit(baseControl, "T -sym-old "+h2+"\n", "T +bgcolor "+h1+" blue\n")),
accepted("artifact.4.5.2.t-target-order", "AF-4.5.2-4",
edit(baseControl, "T -sym-old "+h2, "T +bgcolor "+h2), nil),
rejected("artifact.4.5.2.t-target-descending", "AF-4.5.2-4", edit(baseControl,
"T +bgcolor "+h1+" blue\n", "T +bgcolor "+h2+" blue\nT +bgcolor "+h1+"\n")),
accepted("artifact.4.5.2.t-sign-order", "AF-4.5.2-4", edit(baseControl,
"T +bgcolor "+h1+" blue\n", "T *bgcolor "+h1+" blue\nT +bgcolor "+h1+" blue\n"), nil),
rejected("artifact.4.5.2.t-sign-descending", "AF-4.5.2-4", edit(baseControl,
"T +bgcolor "+h1+" blue\n", "T +bgcolor "+h1+" blue\nT *bgcolor "+h1+" blue\n")),
accepted("artifact.4.5.2.p-order-kept", "AF-4.5.2-5",
edit(baseManifest, "P "+h4+" "+h5, "P "+h5+" "+h4+" "+h5),
func(d *deck.Deck) error { return want("P", d.P, []string{h5, h4, h5}) }),
accepted("artifact.4.5.2.q-repeat", "AF-4.5.2-6",
edit(baseManifest, "Q +"+h6+" "+h7+"\n", "Q +"+h6+" "+h7+"\nQ +"+h6+" "+h7+"\n"),
func(d *deck.Deck) error { return want("len(Q)", len(d.Q), 2) }),
accepted("artifact.4.5.2.q-unordered", "AF-4.5.2-6",
edit(baseManifest, "Q +"+h6+" "+h7+"\n", "Q +"+h6+" "+h7+"\nQ -"+h1+"\n"), nil),
}
// pathnameCases puts each name in the first F card of the manifest base.
// Every name sorts before the base's second file, b/c.txt.
func pathnameCases() []artifactCase {
bad := []struct{ slug, name string }{
{"leading-slash", "/a.txt"}, {"dot", "."}, {"dotdot", ".."},
{"dot-prefix", "./a.txt"}, {"dotdot-prefix", "../a.txt"},
{"double-slash", "a//c.txt"}, {"trailing-slash", "a/"},
{"dot-element", "a/./c.txt"}, {"dotdot-element", "a/../c.txt"},
{"dot-tail", "a/."}, {"dotdot-tail", "a/.."},
}
good := []struct{ slug, wire, decoded string }{
{"backslash", `a\\b.txt`, `a\b.txt`}, {"space", `a\sb.txt`, "a b.txt"},
{"control-byte", "a\x01b.txt", "a\x01b.txt"}, {"invalid-utf8", "a\xffb.txt", "a\xffb.txt"},
{"dotfile", ".a.txt", ".a.txt"}, {"inner-dots", "a..b.txt", "a..b.txt"},
{"dots-prefix", "..a.txt", "..a.txt"}, {"dot-dir", "a/.c.txt", "a/.c.txt"},
}
var cases []artifactCase
for _, b := range bad {
cases = append(cases, rejected("artifact.4.6.1.f-"+b.slug, "AF-4.6.1-1",
edit(baseManifest, "F a.txt ", "F "+b.name+" ")))
}
for _, g := range good {
cases = append(cases, accepted("artifact.4.6.1.f-"+g.slug, "AF-4.6.1-2",
edit(baseManifest, "F a.txt ", "F "+g.wire+" "),
cardIs("F[0].Name", func(d *deck.Deck) string { return d.F[0].Name }, g.decoded)))
}
return append(cases,
rejected("artifact.4.6.1.old-name-leading-slash", "AF-4.6.1-1",
edit(baseManifest, " w old.txt", " w /old.txt")),
rejected("artifact.4.6.1.old-name-dotdot-prefix", "AF-4.6.1-1",
edit(baseManifest, " w old.txt", " w ../old.txt")),
rejected("artifact.4.6.1.a-leading-slash", "AF-4.6.1-1",
edit(baseAttachment, "A report.txt", "A /report.txt")),
rejected("artifact.4.6.1.a-double-slash", "AF-4.6.1-1",
edit(baseAttachment, "A report.txt", "A a//report.txt")),
)
}
// wikiNameCases puts each name in the wiki base's L card.
func wikiNameCases() []artifactCase {
long := strings.Repeat("x", 100)
bad := []struct{ slug, name string }{
{"leading-space", `\sHome`}, {"leading-tab", `\tHome`}, {"inner-tab", `Ho\tme`},
{"inner-lf", `Ho\nme`}, {"double-space", `Home\s\sPage`}, {"trailing-space", `Home\s`},
{"101-bytes", long + "x"},
}
good := []struct{ slug, name string }{
{"one-byte", "x"}, {"100-bytes", long}, {"del-byte", "Home\x7f"},
{"invalid-utf8", "Home\xff\xfe"},
}
var cases []artifactCase
for _, b := range bad {
cases = append(cases, rejected("artifact.4.6.2.l-"+b.slug, "AF-4.6.2-1",
edit(baseWiki, `L Home\sPage`, "L "+b.name)))
}
for _, g := range good {
cases = append(cases, accepted("artifact.4.6.2.l-"+g.slug, "AF-4.6.2-2",
edit(baseWiki, `L Home\sPage`, "L "+g.name),
cardIs("L", func(d *deck.Deck) string { return d.L }, g.name)))
}
return cases
}
// fileNames checks the effective F card list after any reserved-name drop.
func fileNames(names ...string) func(*deck.Deck) error {
return func(d *deck.Deck) error {
var got []string
for _, f := range d.F {
got = append(got, f.Name)
}
return want("F names", got, names)
}
}
var reservedCases = func() []artifactCase {
var cases []artifactCase
// Every name sorts before b/c.txt, so it can replace a.txt in place.
for _, name := range []string{"_FOSSIL_", ".fslckout", "a/_FOSSIL_", "a_FOSSIL_-wal",
"a/.fslckout-journal", "_fossil_", ".FSLCKOUT-SHM"} {
cases = append(cases, loose("artifact.4.6.3.drop-"+name, "AF-4.6.3-1",
edit(baseManifest, "F a.txt ", "F "+name+" "), fileNames("b/c.txt", "d.txt")))
}
for _, name := range []string{"a_FOSSIL_", "a.fslckout", "_FOSSIL_-wal-wal"} {
cases = append(cases, accepted("artifact.4.6.3.keep-"+name, "AF-4.6.3-2",
edit(baseManifest, "F a.txt ", "F "+name+" "), fileNames(name, "b/c.txt", "d.txt")))
}
return append(cases,
accepted("artifact.4.6.3.old-name-not-tested", "AF-4.6.3-2",
edit(baseManifest, " w old.txt", " w _FOSSIL_"),
cardIs("F[2].OldName", func(d *deck.Deck) string { return d.F[2].OldName },
"_FOSSIL_")),
rejected("artifact.4.6.3.order-before-drop", "AF-4.6.3-3", edit(baseManifest,
"F d.txt "+h3+" w old.txt\n", "F d.txt "+h3+" w old.txt\nF _FOSSIL_ "+h8+"\n")),
// c/_FOSSIL_ is dropped, so b/c.txt is compared with a.txt, not
// with the dropped name it sorts below.
loose("artifact.4.6.3.drop-not-anchor", "AF-4.6.3-3",
edit(baseManifest, "F b/c.txt", "F c/_FOSSIL_ "+h8+"\nF b/c.txt"),
fileNames("a.txt", "b/c.txt", "d.txt")),
// The C card makes it a manifest; the dropped F card adds no file.
loose("artifact.4.6.3.drop-only-file", "AF-4.6.3-4",
"C only\\sreserved\nD "+ts+"\nF _FOSSIL_ "+h1+"\nU alice\n",
func(d *deck.Deck) error {
if err := wantType(deck.Checkin)(d); err != nil {
return err
}
return want("F count", len(d.F), 0)
}),
// No C or P card: the dropped F card sets no type, so the artifact
// is a control artifact, which may not carry an F card.
rejected("artifact.5.3.dropped-f-control", "AF-5.3-3",
"D "+ts+"\nF _FOSSIL_ "+h1+"\nU alice\n"),
)
}()
// Forms 3 (time only) and 4 (now, subsec, subsecond) of Section 4.6.4 have no
// case: a reader MAY accept forms 2 through 5, and only form 1 is required.
// deck.Parse leaves them out, since a stored artifact must carry form 1 and
// "now" would make Parse depend on the clock; fossil(1) accepts both, so a
// rejecting case could not pass the fossil check. The date-time path of
// repo-state (SQLite julianday) accepts all five forms, so the two readers
// differ inside the MAY: a hand-made "D 12:00" crosslinks in fossil but Parse
// rejects it. The date-only case below is the one MAY form Parse takes.
// timestampCases puts each token in the manifest base's D card. An
// accepted token is checked as the instant it denotes, in UTC.
func timestampCases() []artifactCase {
good := []struct{ slug, token, instant string }{
{"hh-mm", "2026-01-02T03:04", "2026-01-02T03:04:00.000"},
{"one-digit-fraction", "2026-01-02T03:04:05.1", "2026-01-02T03:04:05.100"},
{"long-fraction", "2026-01-02T03:04:05.123456", "2026-01-02T03:04:05.123"},
{"zone-z", "2026-01-02T03:04:05Z", "2026-01-02T03:04:05.000"},
{"zone-lowercase-z", "2026-01-02T03:04:05z", "2026-01-02T03:04:05.000"},
{"zone-plus", "2026-01-02T03:04:05+01:00", "2026-01-02T02:04:05.000"},
{"zone-minus", "2026-01-02T03:04:05-01:30", "2026-01-02T04:34:05.000"},
{"tab-separator", "2026-01-02\t03:04:05", "2026-01-02T03:04:05.000"},
{"double-separator", "2026-01-02TT03:04:05", "2026-01-02T03:04:05.000"},
{"hour-24", "2026-01-02T24:00:00", "2026-01-03T00:00:00.000"},
{"no-calendar-check", "2024-02-31T03:04:05", "2024-03-02T03:04:05.000"},
{"after-epoch", "-4713-11-25T00:00:00", "-4713-11-25T00:00:00.000"},
}
// Julian day numbers (Section 4.6.4, form 5); 2461042.5 is
// 2026-01-02T00:00:00.
julian := []struct{ slug, token, instant string }{
{"julian-fraction", "2461042.5", "2026-01-02T00:00:00.000"},
{"julian-plus-sign", "+2461042.5", "2026-01-02T00:00:00.000"},
{"julian-exponent", "2.4610425e6", "2026-01-02T00:00:00.000"},
{"julian-exponent-upper", "24610425E-1", "2026-01-02T00:00:00.000"},
{"julian-leading-point", ".5", "-4713-11-25T00:00:00.000"},
{"julian-trailing-point", "2461042.", "2026-01-01T12:00:00.000"},
}
bad := []struct{ slug, req, token string }{
{"lowercase-t", "AF-4.6.4-3", "2026-01-02t03:04:05"},
{"invalid-month", "AF-4.6.4-4", "2026-13-02T03:04:05"},
{"month-00", "AF-4.6.4-4", "2026-00-02T03:04:05"},
{"day-32", "AF-4.6.4-4", "2026-01-32T03:04:05"},
{"day-00", "AF-4.6.4-4", "2026-01-00T03:04:05"},
{"hour-25", "AF-4.6.4-4", "2026-01-02T25:04:05"},
{"minute-60", "AF-4.6.4-4", "2026-01-02T03:60:05"},
{"second-60", "AF-4.6.4-4", "2026-01-02T03:04:60"},
{"three-digit-year", "AF-4.6.4-4", "202-01-02T03:04:05"},
{"five-digit-year", "AF-4.6.4-4", "20260-01-02T03:04:05"},
{"one-digit-month", "AF-4.6.4-4", "2026-1-02T03:04:05"},
{"epoch", "AF-4.6.4-5", "-4713-11-24T12:00:00"},
{"date-only-month-13", "AF-4.6.4-4", "2026-13-02"},
{"date-only-epoch", "AF-4.6.4-5", "-4713-11-24"},
{"zero-julian", "AF-4.6.4-7", "0"},
{"negative-julian", "AF-4.6.4-7", "-1"},
{"julian-upper-bound", "AF-4.6.4-7", "5373484.5"},
{"julian-rounds-up-to-bound", "AF-4.6.4-7", "5373484.4999999999"},
{"julian-rounds-to-zero", "AF-4.6.4-7", "1e-10"},
{"julian-hex", "AF-4.6.4-6", "0x258000"},
{"julian-inf", "AF-4.6.4-6", "inf"},
{"julian-nan", "AF-4.6.4-6", "nan"},
}
var cases []artifactCase
for _, g := range julian {
cases = append(cases, loose("artifact.4.6.4."+g.slug, "AF-4.6.4-6",
edit(baseManifest, "D "+ts, "D "+g.token), func(d *deck.Deck) error {
return want("D", d.D.UTC().Format("2006-01-02T15:04:05.000"), g.instant)
}))
}
for _, g := range good {
cases = append(cases, loose("artifact.4.6.4."+g.slug, "AF-4.6.4-2",
edit(baseManifest, "D "+ts, "D "+g.token), func(d *deck.Deck) error {
return want("D", d.D.UTC().Format("2006-01-02T15:04:05.000"), g.instant)
}))
}
// Form 2: a reader MAY accept a date alone, as midnight. The case sits
// under AF-4.6.4-4, the row that names the date-only form, because the
// range rejections beside it need the accepting side to be real.
cases = append(cases, loose("artifact.4.6.4.date-only", "AF-4.6.4-4",
edit(baseManifest, "D "+ts, "D 2026-01-02"), func(d *deck.Deck) error {
return want("D", d.D.UTC().Format("2006-01-02T15:04:05.000"), "2026-01-02T00:00:00.000")
}))
for _, b := range bad {
cases = append(cases, rejected("artifact.4.6.4."+b.slug, b.req,
edit(baseManifest, "D "+ts, "D "+b.token)))
}
return append(cases,
loose("artifact.4.6.4.fraction-round-trip", "AF-4.6.4-2",
edit(baseManifest, "D "+ts, "D 2026-01-02T03:04:05.123456"), roundTrips),
loose("artifact.4.6.4.e-fraction-round-trip", "AF-4.6.4-2",
edit(baseEvent, "E 2026-01-02T03:04:05 ", "E 2026-01-02T03:04:05.5 "), roundTrips),
rejected("artifact.4.6.4.e-invalid-month", "AF-4.6.4-4",
edit(baseEvent, "E 2026-01-02T", "E 2026-13-02T")),
rejected("artifact.4.6.4.e-lowercase-t", "AF-4.6.4-3",
edit(baseEvent, "E 2026-01-02T", "E 2026-01-02t")),
)
}
// roundTrips checks that the instant the parser read survives Marshal; the
// case itself is non-canonical, since Marshal may write fewer digits.
func roundTrips(d *deck.Deck) error {
encoded, err := d.Marshal()
if err != nil {
return err
}
again, err := deck.Parse(encoded)
if err != nil {
return expectf("deck.Parse rejects what Marshal wrote: %v", err)
}
if !again.D.Equal(d.D) {
return expectf("D is %v after a round trip, was %v", again.D, d.D)
}
if d.E != nil && !again.E.Date.Equal(d.E.Date) {
return expectf("E date is %v after a round trip, was %v", again.E.Date, d.E.Date)
}
return nil
}
var cardCases = []artifactCase{
accepted("artifact.4.7.f-sha3", "AF-4.7.1-1", edit(baseManifest, "F a.txt "+h1, "F a.txt "+h64),
cardIs("F[0].UUID", func(d *deck.Deck) string { return d.F[0].UUID }, h64)),
rejected("artifact.4.7.k-sha3-reject", "AF-4.7.1-2", edit(baseTicket, "K "+h1, "K "+h64)),
accepted("artifact.4.7.f-uppercase-hash", "AF-3.3-2",
edit(baseManifest, "F a.txt "+h1, "F a.txt "+strings.ToUpper(h1)),
cardIs("F[0].UUID", func(d *deck.Deck) string { return d.F[0].UUID }, strings.ToUpper(h1))),
loose("artifact.4.7.2.a-tail", "AF-4.7.2-1",
edit(baseAttachment, "A report.txt", "A dir/report.txt"),
cardIs("A.Filename", func(d *deck.Deck) string { return d.A.Filename }, "report.txt")),
accepted("artifact.4.7.2.a-target-wiki", "AF-4.7.2-2",
edit(baseAttachment, "A report.txt "+h1, "A report.txt HomePage"),
cardIs("A.Target", func(d *deck.Deck) string { return d.A.Target }, "HomePage")),
rejected("artifact.4.7.2.a-target-bad", "AF-4.7.2-2",
edit(baseAttachment, "A report.txt "+h1, `A report.txt Home\s\sPage`)),
accepted("artifact.4.7.2.a-no-source", "AF-4.7.2-3",
edit(baseAttachment, "A report.txt "+h1+" "+h2, "A report.txt "+h1),
cardIs("A.Source", func(d *deck.Deck) string { return d.A.Source }, "")),
rejected("artifact.4.7.2.a-empty-source", "AF-4.7.2-3",
edit(baseAttachment, "A report.txt "+h1+" "+h2, "A report.txt "+h1+" ")),
rejected("artifact.4.7.c-missing", "AF-4.7.4-1",
edit(baseAttachment, "C attached\\sa\\sreport\n", "C\n")),
rejected("artifact.4.7.5.e-one-token", "AF-4.7.5-1",
edit(baseEvent, "E 2026-01-02T03:04:05 "+h1, "E 2026-01-02T03:04:05")),
rejected("artifact.4.7.f-no-hash-baseline", "AF-4.7.6-1",
edit(baseManifest, "F a.txt "+h1+"\n", "F a.txt\n")),
accepted("artifact.4.7.f-deletion-delta", "AF-4.7.6-1", baseDelta,
cardIs("F[1]", func(d *deck.Deck) string { return d.F[1].Name + "=" + d.F[1].UUID },
"gone.txt=")),
accepted("artifact.4.7.f-opaque-permission", "AF-4.7.6-2",
edit(baseManifest, "F a.txt "+h1+"\n", "F a.txt "+h1+" strange\n"),
cardIs("F[0].Perm", func(d *deck.Deck) string { return d.F[0].Perm }, "strange")),
accepted("artifact.4.7.6.third-is-permission", "AF-4.7.6-2",
edit(baseManifest, "F a.txt "+h1+"\n", "F a.txt "+h1+" old.txt\n"),
cardIs("F[0]", func(d *deck.Deck) string { return d.F[0].Perm + "|" + d.F[0].OldName },
"old.txt|")),
accepted("artifact.4.7.j-absent-value", "AF-4.7.8-1",
edit(baseTicket, "J status Open", "J status"),
cardIs("J[1].Value", func(d *deck.Deck) string { return d.J[1].Value }, "")),
// Section 4.7.8: a present empty value (J, SP, name, SP, LF), the form
// fossil writes when a ticket field is cleared, parses to the same
// field as an absent one.
loose("artifact.4.7.8.j-present-empty", "AF-4.7.8-1",
edit(baseTicket, "J status Open", "J status "), func(d *deck.Deck) error {
absent, err := deck.Parse(withZ(edit(baseTicket, "J status Open", "J status")))
if err != nil {
return expectf("parse the absent form: %v", err)
}
return want("J", d.J, absent.J)
}),
accepted("artifact.4.7.p-empty", "AF-4.7.13-1", edit(baseManifest, "P "+h4+" "+h5+"\n", "P\n"),
func(d *deck.Deck) error { return want("len(P)", len(d.P), 0) }),
rejected("artifact.4.7.q-invalid-sign", "AF-4.7.14-1", edit(baseManifest, "Q +", "Q *")),
accepted("artifact.4.7.14.q-no-baseline", "AF-4.7.14-1",
edit(baseManifest, "Q +"+h6+" "+h7, "Q +"+h6),
func(d *deck.Deck) error { return want("Q[0]", d.Q[0], deck.CherryPick{Target: h6}) }),
accepted("artifact.4.7.14.q-backout", "AF-4.7.14-1", edit(baseManifest, "Q +", "Q -"),
func(d *deck.Deck) error { return want("Q[0].IsBackout", d.Q[0].IsBackout, true) }),
rejected("artifact.4.7.16.t-bad-sign", "AF-4.7.16-1",
edit(baseControl, "T +bgcolor", "T !bgcolor")),
rejected("artifact.4.7.t-hex-name", "AF-4.7.16-2", edit(baseControl, "T +bgcolor", "T +abc")),
rejected("artifact.4.7.16.t-hex-name-uppercase", "AF-4.7.16-2",
edit(baseControl, "T +bgcolor", "T +ABC")),
rejected("artifact.4.7.16.t-hex-name-long", "AF-4.7.16-2",
edit(baseControl, "T +bgcolor", "T +"+h1+"0")),
rejected("artifact.4.7.16.t-bad-target", "AF-4.7.16-3",
edit(baseControl, "T +bgcolor "+h1, "T +bgcolor **")),
loose("artifact.4.7.u-empty", "AF-4.7.17-1", edit(baseManifest, "U alice\n", "U\n"),
cardIs("U", func(d *deck.Deck) string { return *d.U }, "anonymous")),
loose("artifact.4.7.u-space-empty", "AF-4.7.17-1", edit(baseManifest, "U alice\n", "U \n"),
cardIs("U", func(d *deck.Deck) string { return *d.U }, "anonymous")),
accepted("artifact.4.7.w-opaque", "AF-4.7.18-1",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 5\na\n\x00b!\n"),
cardIs("W", func(d *deck.Deck) string { return string(d.W) }, "a\n\x00b!")),
accepted("artifact.4.7.18.w-body-cards", "AF-4.7.18-1",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 6\nU bob\n\n"),
cardIs("W", func(d *deck.Deck) string { return string(d.W) }, "U bob\n")),
accepted("artifact.4.7.18.w-empty", "AF-4.7.18-1",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 0\n\n"),
func(d *deck.Deck) error { return want("len(W)", len(d.W), 0) }),
// The body is followed by "!" where LF belongs, then the Z card.
raw("artifact.4.7.w-missing-lf", "AF-4.7.18-2", edit(baseWiki, "world\n\n", "world\n!")+
zCard(edit(baseWiki, "world\n\n", "world\n!")), false),
loose("artifact.4.7.w-count-suffix", "AF-4.7.18-3", edit(baseWiki, "W 12\n", "W 12x\n"),
cardIs("W", func(d *deck.Deck) string { return string(d.W) }, "hello\nworld\n")),
loose("artifact.4.7.w-nondigit-zero", "AF-4.7.18-3",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W abc\n\n"),
func(d *deck.Deck) error { return want("len(W)", len(d.W), 0) }),
loose("artifact.4.7.18.w-leading-zero", "AF-4.7.18-3", edit(baseWiki, "W 12\n", "W 012\n"),
cardIs("W", func(d *deck.Deck) string { return string(d.W) }, "hello\nworld\n")),
// "+12" has no leading digit, so its size is 0 and the byte after the
// empty body is "h", not LF.
rejected("artifact.4.7.18.w-plus-sign", "AF-4.7.18-3", edit(baseWiki, "W 12\n", "W +12\n")),
rejected("artifact.4.7.w-short", "AF-4.7.18-4", edit(baseWiki, "W 12\n", "W 999\n")),
rejected("artifact.4.7.w-overflow", "AF-4.7.18-4",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 184467440737095516160\n\n")),
// UINT64_MAX must not wrap the body-plus-LF bounds check to zero.
rejected("artifact.4.7.18.w-max-uint64", "AF-4.7.18-4",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 18446744073709551615\n\n")),
rejected("artifact.4.7.18.w-overflow-increasing", "AF-4.7.18-4",
edit(baseWiki, "W 12\nhello\nworld\n\n", "W 30000000000000000000\n\n")),
// 2^64 + 12: an accumulator that wraps instead of failing reads 12.
rejected("artifact.4.7.18.w-overflow-wrap", "AF-4.7.18-4",
edit(baseWiki, "W 12\n", "W 18446744073709551628\n")),
raw("artifact.4.7.z-extra-token", "AF-4.2.1-1",
baseControl+strings.TrimSuffix(zCard(baseControl), "\n")+" extra\n", false),
}
// typeCases checks each base's type, then the type rules of Section 5.
func typeCases() []artifactCase {
var cases []artifactCase
for _, b := range artifactBases {
if b.name == "delta" {
continue
}
cases = append(cases, accepted("artifact.5.1."+b.name, "AF-5.1-1",
strings.Join(b.cards, ""), wantType(b.goType)))
}
return append(cases,
rejected("artifact.5.3.latest-setter-illegal", "AF-5.3-1",
edit(baseWiki, "C edit", "A x.txt "+h8+" "+h9+"\nC edit")),
accepted("artifact.5.3.parent-fallback", "AF-5.3-2", edit(baseControl,
"T +bgcolor "+h1+" blue\nT -sym-old "+h2+"\n", "P "+h1+"\n"), wantType(deck.Checkin)),
accepted("artifact.5.3.comment-fallback", "AF-5.3-2",
edit(baseControl, "D ", "C note\nD "), wantType(deck.Checkin)),
// ยง5.3 / fossil manifest.c:1076: the fallback keys on C-card presence
// (zComment != 0), not a non-empty comment, so a present-but-empty C
// still makes a tagless D/T/U artifact a check-in, not a control.
accepted("artifact.5.3.comment-fallback-empty", "AF-5.3-2",
edit(baseControl, "D ", "C \nD "), wantType(deck.Checkin)),
accepted("artifact.5.3.control-fallback", "AF-5.3-2", baseControl, wantType(deck.Control)),
rejected("artifact.5.4.control-self", "AF-5.4-1",
edit(baseControl, "T -sym-old "+h2, "T -sym-old *")),
rejected("artifact.5.4.event-external-tag", "AF-5.4-2",
edit(baseEvent, "T +bgcolor *", "T +bgcolor "+h3)),
rejected("artifact.5.4.event-propagating", "AF-5.4-2",
edit(baseEvent, "T +bgcolor", "T *bgcolor")),
rejected("artifact.5.4.event-cancel", "AF-5.4-2",
edit(baseEvent, "T +bgcolor", "T -bgcolor")),
rejected("artifact.5.4.forum-title-reply", "AF-5.4-3",
edit(baseForum, "I "+h2+"\n", "H title\nI "+h2+"\n")),
rejected("artifact.5.4.forum-many-parents", "AF-5.4-4",
edit(baseForum, "P "+h3, "P "+h3+" "+h4)),
)
}
var checksumCases = []artifactCase{
rejected("artifact.6.1.r-nonhex", "AF-6.1-1",
edit(baseManifest, "R "+md5Token, "R "+strings.Repeat("g", 32))),
rejected("artifact.6.1.r-short", "AF-6.1-1",
edit(baseManifest, "R "+md5Token, "R "+md5Token[:31])),
// Parse keeps the token as written; Marshal renders it lowercase (6.3.4).
loose("artifact.6.1.r-uppercase", "AF-6.1-1",
edit(baseManifest, "R "+md5Token, "R "+strings.ToUpper(md5Token)),
cardIs("R", func(d *deck.Deck) string { return d.R }, strings.ToUpper(md5Token))),
zRejected("artifact.6.1.z-short", "AF-6.1-1", baseControl+zCard(baseControl)[:33]+"\n"),
zRejected("artifact.6.2.mismatch", "AF-6.2-1", baseControl+"Z "+strings.Repeat("0", 32)+"\n"),
zRejected("artifact.6.2.uppercase", "AF-6.2-2",
baseControl+strings.ToUpper(zCard(baseControl))),
zRejected("artifact.6.2.missing", "AF-6.2-3", baseControl),
// Section 6.2: with no Z card, a last line that ends in "Z " and 32
// more bytes is still checked, and fossil reports the checksum.
says(zRejected("artifact.6.2.z-by-position", "AF-6.2-7",
edit(baseControl, "U alice\n", "U aliceZ "+strings.Repeat("0", 32)+"\n")),
"Z-card cksum"),
// The Z card covers the escaped wire bytes, not the decoded comment.
accepted("artifact.6.2.raw-before-decode", "AF-6.2-2", baseManifest,
cardIs("C", func(d *deck.Deck) string { return d.C }, "base manifest")),
accepted("artifact.6.3.1.r-unchecked", "AF-6.3-1", baseManifest,
cardIs("R", func(d *deck.Deck) string { return d.R }, md5Token)),
}
// Generated cases: one per card letter or hash position, each a single
// edit of a base.
// cardLine names one card line of a base.
type cardLine struct {
letter byte
base string
line string
}
func duplicateCases() []artifactCase {
lines := []cardLine{
{'A', baseAttachment, attachmentCards[0]}, {'B', baseDelta, deltaCards[0]},
{'C', baseManifest, manifestCards[0]}, {'D', baseManifest, manifestCards[1]},
{'E', baseEvent, eventCards[2]}, {'G', baseForum, forumCards[1]},
{'I', baseForum, forumCards[2]}, {'K', baseTicket, ticketCards[3]},
{'L', baseWiki, wikiCards[2]}, {'N', baseWiki, wikiCards[3]},
{'R', baseManifest, manifestCards[7]}, {'U', baseManifest, manifestCards[10]},
{'W', baseWiki, wikiCards[6]},
}
var cases []artifactCase
for _, l := range lines {
c := rejected(fmt.Sprintf("artifact.4.5.1.duplicate-%c", l.letter),
"AF-4.5.1-1", edit(l.base, l.line, l.line+l.line))
c.fossilSays = "more than one " + string(l.letter) + "-card"
if l.letter == 'A' {
// fossil 2.28 rejects a second A card with no message of its own.
c.fossilSays = "unknown error on line 2"
}
cases = append(cases, c)
}
// No base has an H card: a reply post has I, which H may not join.
return append(cases, rejected("artifact.4.5.1.duplicate-H", "AF-4.5.1-1",
edit(baseForum, "I "+h2+"\n", "H title\nH title\n")))
}
// arityCases adds one token to a card already at its full arity.
func arityCases() []artifactCase {
lines := []cardLine{
{'A', baseAttachment, attachmentCards[0]}, {'B', baseDelta, deltaCards[0]},
{'C', baseManifest, manifestCards[0]}, {'D', baseManifest, manifestCards[1]},
{'E', baseEvent, eventCards[2]}, {'F', baseManifest, manifestCards[4]},
{'G', baseForum, forumCards[1]}, {'I', baseForum, forumCards[2]},
{'J', baseTicket, ticketCards[2]}, {'K', baseTicket, ticketCards[3]},
{'L', baseWiki, wikiCards[2]}, {'M', baseCluster, clusterCards[0]},
{'N', baseWiki, wikiCards[3]}, {'Q', baseManifest, manifestCards[6]},
{'R', baseManifest, manifestCards[7]}, {'T', baseControl, controlCards[1]},
{'U', baseManifest, manifestCards[10]}, {'W', baseWiki, wikiCards[6]},
}
var cases []artifactCase
for _, l := range lines {
first := l.line[:strings.IndexByte(l.line, '\n')+1]
c := rejected(fmt.Sprintf("artifact.4.2.1.extra-%c", l.letter),
"AF-4.2.1-1", edit(l.base, first, strings.TrimSuffix(first, "\n")+" extra\n"))
c.fossilSays = "extra characters at end of card"
if l.letter == 'W' {
// fossil reads the size, then finds no body where it expects one.
c.fossilSays = "no content after W-card"
}
cases = append(cases, c)
}
return append(cases, rejected("artifact.4.2.1.extra-H", "AF-4.2.1-1",
edit(baseForum, "I "+h2+"\n", "H title extra\n")))
}
// hashCases breaks the hash at each hash-bearing position three ways. The
// A target is left out: a broken hash there is still a valid wiki name.
func hashCases() []artifactCase {
positions := []struct {
slug, base, line, hash string
}{
{"a-source", baseAttachment, attachmentCards[0], h2},
{"b", baseDelta, deltaCards[0], h1},
{"e-id", baseEvent, eventCards[2], h1},
{"f", baseManifest, manifestCards[2], h1},
{"g", baseForum, forumCards[1], h1},
{"i", baseForum, forumCards[2], h2},
{"k", baseTicket, ticketCards[3], h1},
{"m", baseCluster, clusterCards[0], h1},
{"p", baseManifest, manifestCards[5], h4},
{"q-target", baseManifest, manifestCards[6], h6},
{"q-baseline", baseManifest, manifestCards[6], h7},
{"t-target", baseControl, controlCards[1], h1},
}
var cases []artifactCase
for _, p := range positions {
req := "AF-4.7.1-1"
if p.slug == "k" {
req = "AF-4.7.1-2"
}
for _, bad := range []struct{ slug, hash string }{
{"short", p.hash[:39]}, {"long", p.hash + "0"}, {"nonhex", p.hash[:39] + "g"},
} {
line := strings.Replace(p.line, p.hash, bad.hash, 1)
cases = append(cases, rejected("artifact.4.7.1."+p.slug+"-"+bad.slug, req,
edit(p.base, p.line, line)))
}
}
return cases
}
// legalitySamples is a well-formed card of each letter, for insertion into
// a base whose type does not allow it.
var legalitySamples = map[byte]string{
'A': "A x.txt " + h8 + " " + h9 + "\n", 'B': "B " + h8 + "\n", 'C': "C inserted\n",
'D': "D " + ts + "\n", 'E': "E 2026-01-02T03:04:05 " + h8 + "\n", 'F': "F x.txt " + h8 + "\n",
'G': "G " + h8 + "\n", 'H': "H title\n", 'I': "I " + h8 + "\n", 'J': "J field value\n",
'K': "K " + h8 + "\n", 'L': "L Page\n", 'M': "M " + h8 + "\n", 'N': "N text/plain\n",
'P': "P " + h8 + "\n", 'Q': "Q +" + h8 + "\n", 'R': "R " + md5Token + "\n",
'T': "T +tag " + h8 + "\n", 'U': "U bob\n", 'W': "W 1\nx\n",
}
// legalityCases inserts each disallowed letter into each base, and removes
// each required one. A change the draft's own tables would accept, because
// it turns the artifact into another valid type, is left out: the cases are
// about rejection.
func legalityCases() []artifactCase {
var cases []artifactCase
for _, b := range artifactBases {
if b.name == "delta" {
continue
}
for letter := byte('A'); letter <= 'W'; letter++ {
sample, ok := legalitySamples[letter]
if !ok || strings.IndexByte(b.allowed, letter) >= 0 {
continue
}
id := fmt.Sprintf("artifact.5.2.%s-illegal-%c", b.name, letter)
cases = appendIllegal(cases, id, insertCard(b.cards, sample))
}
for _, letter := range []byte(strings.TrimSuffix(b.required, "Z")) {
id := fmt.Sprintf("artifact.5.2.%s-no-%c", b.name, letter)
cases = appendIllegal(cases, id, removeCards(b.cards, letter))
}
}
return cases
}
// appendIllegal adds a rejection case for cards when the draft's tables
// reject them. The requirement is the one the tables break, which is not
// always the one the edit aimed at: removing a wiki's L card leaves no
// type-setting card, so the artifact falls back to a manifest and its W
// card is what is illegal.
func appendIllegal(cases []artifactCase, id string, cards []string) []artifactCase {
req := draftRule(cards)
if req == "" {
return cases
}
c := rejected(id, req, strings.Join(cards, ""))
// fossil names the first card it finds out of place, or missing.
c.fossilSays = "-card in "
if req == "AF-5.2-2" {
c.fossilSays = "-card missing in "
}
return append(cases, c)
}
// insertCard puts card before the first card whose letter sorts after it.
func insertCard(cards []string, card string) []string {
at := len(cards)
for i, c := range cards {
if c[0] > card[0] {
at = i
break
}
}
out := append([]string{}, cards[:at]...)
out = append(out, card)
return append(out, cards[at:]...)
}
func removeCards(cards []string, letter byte) []string {
var out []string
for _, c := range cards {
if c[0] != letter {
out = append(out, c)
}
}
return out
}
// draftRule applies the draft's type determination (Section 5.3) and card
// table (Section 5.2) to cards, which carry no Z card yet. It returns the
// requirement they break, an illegal card before a missing one as fossil
// checks them, or "" when the tables accept them.
func draftRule(cards []string) string {
setters := map[byte]string{'A': "attachment", 'B': "manifest", 'F': "manifest",
'Q': "manifest", 'R': "manifest", 'E': "event", 'G': "forum", 'H': "forum",
'I': "forum", 'J': "ticket", 'K': "ticket", 'L': "wiki", 'M': "cluster"}
kind, present := "", "Z"
for _, c := range cards {
if s, ok := setters[c[0]]; ok {
kind = s
}
present += string(c[0])
}
if kind == "" {
kind = "control"
if strings.ContainsAny(present, "CP") {
kind = "manifest"
}
}
for _, b := range artifactBases {
if b.name != kind {
continue
}
for _, letter := range []byte(present) {
if strings.IndexByte(b.allowed, letter) < 0 {
return "AF-5.2-1"
}
}
for _, letter := range []byte(b.required) {
if strings.IndexByte(present, letter) < 0 {
return "AF-5.2-2"
}
}
return ""
}
panic("conformance: draftRule: no base for type " + kind)
}