Artifact afc92f4c0e41287bff6107b03e70bd5eb64cfb8792fda2496c6dfa31ae48bb5f:
- File
internal/artifact/artifact_test.go
— part of check-in
[5773517bde]
at
2026-08-16 16:17:52
on branch trunk
— artifact: an empty trailing token is legal on any card
Broadens the previous J/T/U fix: fossil's next_token() returns a non-NULL empty string whenever a card ends in a space, on any letter, and each card's own validation decides. C is the common case — 1,009 empty-comment artifacts across sqlite and tcl were rejected and demoted to content. Interior empty tokens stay malformed, and arity still gates the rest. (user: dmestas, size: 10932) [annotate] [blame] [check-ins using]
package artifact import ( "bytes" "crypto/md5" "encoding/hex" "fmt" "os" "path/filepath" "strings" "testing" ) // zseal appends a valid Z card to a card body — test artifacts must pass // the §6.2 self-checksum gate. func zseal(body string) []byte { sum := md5.Sum([]byte(body)) return []byte(body + "Z " + hex.EncodeToString(sum[:]) + "\n") } func parseOK(t *testing.T, body string) *Artifact { t.Helper() a, err := Parse(zseal(body)) if err != nil { t.Fatal(err) } if a == nil { t.Fatal("classified as content") } return a } func wantReject(t *testing.T, body, msg string) { t.Helper() a, err := Parse(zseal(body)) if err == nil { t.Fatalf("%s: want reject, got %+v", msg, a) } } const h40 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const h64 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" // --- corpus: every stored artifact of the synthetic fixture --- func TestCorpus(t *testing.T) { files, err := filepath.Glob("testdata/*") if err != nil || len(files) == 0 { t.Fatalf("no corpus: %v", err) } counts := map[string]int{} for _, f := range files { if fi, err := os.Stat(f); err != nil || fi.IsDir() { continue } raw, err := os.ReadFile(f) if err != nil { t.Fatal(err) } // testdata is stored sterilized (§4.1.2): a '#' first line keeps // these artifacts from joining THIS repository's DAG when // committed. Strip it to recover the exact stored bytes. if i := bytes.IndexByte(raw, '\n'); i < 0 || raw[0] != '#' { t.Fatalf("%s: testdata not sterilized", f) } else { raw = raw[i+1:] } a, err := Parse(raw) if err != nil { t.Errorf("%s: %v", filepath.Base(f), err) continue } if a == nil { counts["blob"]++ continue } counts[a.Type.String()]++ if a.Date == "" && a.Type != Cluster { t.Errorf("%s: %s with no date", filepath.Base(f), a.Type) } } // The fixture builds 5 check-ins, 4 wiki artifacts (3 creates + 1 // edit), 4 ticket artifacts (3 adds + 1 change), and 4 content blobs // (two README versions, main.go, NOTES). want := map[string]int{"checkin": 5, "wiki": 4, "ticket": 4, "blob": 4} for k, n := range want { if counts[k] != n { t.Errorf("corpus %s: got %d, want %d (all: %v)", k, counts[k], n, counts) } } } // --- classification --- func TestClassifyManifest(t *testing.T) { a := parseOK(t, "C fix\\sbug\nD 2026-08-15T10:00:00\nF src/main.go "+h64+"\nP "+h64+"\nU dan\n") if a.Type != Manifest || a.Comment != "fix bug" || a.Files[0].Name != "src/main.go" { t.Fatalf("%+v", a) } } func TestClassifyWiki(t *testing.T) { a := parseOK(t, "D 2026-08-15T10:00:00\nL Home\\sPage\nU dan\nW 6\nhello\n\n") if a.Type != Wiki || a.WikiTitle != "Home Page" || string(a.Body) != "hello\n" { t.Fatalf("%+v", a) } } func TestClassifyTicket(t *testing.T) { a := parseOK(t, "D 2026-08-15T10:00:00\nJ status Open\nJ title crash\nK "+h40+"\nU dan\n") if a.Type != Ticket || a.TicketID != h40 || len(a.Fields) != 2 { t.Fatalf("%+v", a) } } func TestClassifyControlFallback(t *testing.T) { a := parseOK(t, "D 2026-08-15T10:00:00\nT +closed "+h64+"\nU dan\n") if a.Type != Control { t.Fatalf("want control, got %s", a.Type) } } func TestClassifyManifestFallbackViaComment(t *testing.T) { a := parseOK(t, "C empty\nD 2026-08-15T10:00:00\nT *branch * trunk\nT *sym-trunk *\nU dan\n") if a.Type != Manifest { t.Fatalf("want checkin, got %s", a.Type) } } // --- framing / opaque fallthrough --- func TestOpaqueBlobs(t *testing.T) { for _, blob := range []string{ "", "plain text\n", "no trailing newline", "c lowercase first\n", "# sterilized manifest\nD 2026-08-15T10:00:00\n", "X\n", // line 1 too short } { a, err := Parse([]byte(blob)) if a != nil || err != nil { t.Errorf("%q: want (nil,nil), got (%v,%v)", blob, a, err) } } } func TestZMismatchIsContent(t *testing.T) { bad := "C hi\nD 2026-08-15T10:00:00\nU dan\nZ 00000000000000000000000000000000\n" a, err := Parse([]byte(bad)) if a != nil || err != nil { t.Fatalf("Z mismatch must classify as content, got (%v,%v)", a, err) } } func TestMissingZRejected(t *testing.T) { // Passes framing (Z absent is tolerated there) but every type // requires Z (§5.2). a, err := Parse([]byte("C hi\nD 2026-08-15T10:00:00\nU dan\n")) if err == nil { t.Fatalf("want reject for missing Z, got %+v", a) } } // --- grammar rejections --- func TestGrammarRejections(t *testing.T) { d := "D 2026-08-15T10:00:00\n" for name, body := range map[string]string{ "undefined card": d + "S what\nU dan\n", "card out of order": "U dan\n" + d, "duplicate single": "C a\nC b\n" + d + "U dan\n", "excess arity B": "B " + h64 + " extra\n" + d + "F a " + h64 + "\n", "bad hash length": "B abcdef\n" + d + "F a " + h64 + "\n", "F not ascending": d + "F b.txt " + h64 + "\nF a.txt " + h64 + "\n", "F duplicate name": d + "F a.txt " + h64 + "\nF a.txt " + h64 + "\n", "hashless F no B": d + "F gone.txt\n", "bad pathname": d + "F /abs " + h64 + "\n", "dotdot pathname": d + "F a/../b " + h64 + "\n", "bad timestamp": "C a\nD yesterday\nU dan\n", "day zero": "C a\nD 2026-08-00T10:00:00\nU dan\n", "ticket id 64hex": d + "J a b\nK " + h64 + "\nU dan\n", "J not ascending": d + "J b 1\nJ a 2\nK " + h40 + "\nU dan\n", "forum H and I": d + "G " + h64 + "\nH t\nI " + h64 + "\nU dan\nW 0\n\n", "forum merge parents": d + "I " + h64 + "\nP " + h64 + " " + h40 + "\nU dan\nW 0\n\n", "card illegal in type": d + "K " + h40 + "\nJ a b\nU dan\nW 2\nhi\n", "tag without sign": d + "T closed " + h64 + "\nU dan\n", } { wantReject(t, body, name) } } func TestJOrderCheckPrecedesK(t *testing.T) { // J cards sort before K; the ascending check must not depend on K. a := parseOK(t, "D 2026-08-15T10:00:00\nJ alpha 1\nJ beta 2\nK "+h40+"\nU dan\n") if len(a.Fields) != 2 { t.Fatalf("%+v", a) } } // --- decoding --- func TestDecode(t *testing.T) { for in, want := range map[string]string{ `a\sb`: "a b", `a\nb`: "a\nb", `a\tb`: "a\tb", `a\\b`: `a\b`, `a\qb`: "aqb", // unrecognized: drop backslash `trail\`: `trail\`, `plain`: "plain", `\0`: "\x00", `\r\v\f`: "\r\v\f", } { if got := decode(in); got != want { t.Errorf("decode(%q) = %q, want %q", in, got, want) } } } func TestFieldNameNotDecoded(t *testing.T) { a := parseOK(t, "D 2026-08-15T10:00:00\nJ has\\sslash v\\salue\nK "+h40+"\nU dan\n") if a.Fields[0].Name != `has\sslash` { t.Fatalf("field name was decoded: %q", a.Fields[0].Name) } if a.Fields[0].Value != "v alue" { t.Fatalf("field value not decoded: %q", a.Fields[0].Value) } } // --- timestamps --- func TestTimestamps(t *testing.T) { ok := []string{ "2026-08-15T10:00:00", "2026-08-15T10:00:00.604", "2026-08-15", "2026-08-15T10:00", "2026-08-15T10:00:00Z", "2026-08-15T10:00:00+05:30", "10:30", "10:30:00.5", } bad := []string{"now", "2454.5", "2026-8-15", "2026-08-15T25:00:00", "2026-13-01"} for _, s := range ok { if !validTimestamp(s) { t.Errorf("rejected valid %q", s) } } for _, s := range bad { if validTimestamp(s) { t.Errorf("accepted %q", s) } } } // --- misc semantics --- func TestAnonymousUserDefault(t *testing.T) { a := parseOK(t, "C x\nD 2026-08-15T10:00:00\n") if a.User != "anonymous" { t.Fatalf("user = %q", a.User) } } func TestWBodyWithEmbeddedCards(t *testing.T) { // The W body may contain lines that look like cards; the size framing // must keep them as body bytes. body := "D 2026-08-15\nU dan\n" a := parseOK(t, fmt.Sprintf("D 2026-08-15T10:00:00\nL X\nU dan\nW %d\n%s\n", len(body), body)) if string(a.Body) != body { t.Fatalf("body = %q", a.Body) } } func TestDeltaManifestDeletion(t *testing.T) { a := parseOK(t, "B "+h64+"\nC del\nD 2026-08-15T10:00:00\nF gone.txt\nU dan\n") if a.Baseline != h64 || a.Files[0].Hash != "" { t.Fatalf("%+v", a) } } func TestTechnoteTagRule(t *testing.T) { base := "C n\nD 2026-08-15T10:00:00\nE 2026-08-15T09:00:00 " + h40 + "\n" parseOK(t, base+"T +tagged *\nU dan\nW 2\nhi\n") wantReject(t, base+"T +tagged "+h64+"\nU dan\nW 2\nhi\n", "technote tag must be self-ref") wantReject(t, strings.Replace(base+"T -tagged *\nU dan\nW 2\nhi\n", "", "", 1), "technote tag must be +") } // Real Fossil writes an empty trailing value as a bare trailing space. // Rejecting it cost sqlite every one of its 2,100 ticket changes: the // artifact fell back to "content" and the ticket vanished from the index. func TestEmptyTrailingValueIsAccepted(t *testing.T) { hash := strings.Repeat("a", 40) for _, tc := range []struct { name, body string check func(*Artifact) error }{ {"J card empty value", "D 2018-03-11T03:15:15.716\nJ comment \nK " + hash + "\nU dan\n", func(a *Artifact) error { if len(a.Fields) != 1 || a.Fields[0].Name != "comment" || a.Fields[0].Value != "" { return fmt.Errorf("fields = %+v", a.Fields) } return nil }}, {"T card empty value", "D 2018-03-11T03:15:15.716\nT +date " + hash + " \nU dan\n", func(a *Artifact) error { if len(a.Tags) != 1 || a.Tags[0].Name != "+date" || a.Tags[0].Value != "" { return fmt.Errorf("tags = %+v", a.Tags) } return nil }}, } { t.Run(tc.name, func(t *testing.T) { a, err := Parse([]byte(zseal(tc.body))) if err != nil { t.Fatalf("rejected a real Fossil artifact: %v", err) } if err := tc.check(a); err != nil { t.Fatal(err) } }) } } // The tolerance is trailing-only, and only for cards whose grammar ends // in an optional value — an interior empty token is still malformed. func TestInteriorEmptyTokenStillRejected(t *testing.T) { hash := strings.Repeat("a", 40) for _, body := range []string{ "D 2018-03-11T03:15:15.716\nT +date " + hash + "\nU dan\n", // interior "D 2018-03-11T03:15:15.716\nC hi \nU dan\n", // C has no optional value } { if _, err := Parse([]byte(zseal(body))); err == nil { t.Fatalf("accepted malformed artifact: %q", body) } } } // An empty comment is a present, empty value — fossil's next_token // returns "" for the trailing space and the C card accepts it. 1,009 // artifacts across sqlite and tcl carry one. func TestEmptyCommentIsAccepted(t *testing.T) { a := parseOK(t, "C \nD 2026-08-15T10:00:00\nU dan\n") if a.Type != Manifest || a.Comment != "" { t.Fatalf("%+v", a) } } // A bare "C" with no space is not an empty comment: fossil's next_token // returns NULL there, so the value is absent rather than empty. Quarry // never reaches the card rule — a one-byte first line fails framing — // but either way it must not yield a check-in with an empty comment. func TestBareCardLetterIsNotAnEmptyValue(t *testing.T) { a, err := Parse(zseal("C\nD 2026-08-15T10:00:00\nU dan\n")) if err == nil && a != nil { t.Fatalf("bare C accepted as an artifact: %+v", a) } }