Artifact fed491d1bf673b623a4a4c7e1f3c28987e14d23a7485ab27f0425b3ae824eada:
- File
conformance/artifact_api_test.go
— part of check-in
[d52a6fe050]
at
2026-10-11 00:42:57
on branch trunk
— test(conformance): artifact-format conformance suite (#263) (#276)
* test(conformance): artifact-format conformance suite (#263)
Adds the suite for draft-fossil-artifact-format-00 with errata 4.1.2, 4.2 and 4.6.4, so the parser, writer, naming, hash policy and delta manifest code are checked requirement by requirement instead of by scattered unit tests that each encode one reading of fossil.
- requirements/artifact.tsv: 101 requirements from Sections 3 to 8, pinned to the draft and the three errata. - Parse cases are a known-good base artifact per type plus one edit, with the Z card recomputed, so the edit alone explains a rejection. Every case also runs through fossil test-parse-manifest; fossil 2.28 agrees with all of them. Card legality, duplicates, arity and hash positions are generated from the bases and the draft's own tables. - Accepted inputs must re-encode byte for byte unless marked non-canonical. - API cases cover naming, verification, the comparator, the writer, the R card, the hash policy and delta-manifest enumeration. - FuzzArtifactRoundTrip checks parse/marshal/parse stability, and TestArtifactCorpus (CONFORMANCE_REPOS) parses every artifact fossil crosslinked in real repositories.
267 cases go-libfossil fails are marked with a "#0" placeholder in artifact_gaps_test.go, grouped by the code at fault, for issues to be filed against.
* test(conformance): tighten the artifact suite after review (#263)
A rejection only counts when the rule the case names makes it, so every rejection case that is not about the Z card must now pass deck.VerifyZ first. Inputs go-libfossil rejected only through the Z check (framing, residue, sterilizer, unstripped wrappers, an extra Z token) become gaps, along with VerifyZ itself, which rejects an absent Z card and reads the last byte where Section 6.2 says it must not.
- Cases can name the substring fossil's rejection reason must contain; the unknown-letter cases sit in sorted position, the sterilizer cases are filed under card order, which is what fossil rejects them for, and generated legality cases take their requirement from the draft's table. - The ratchet that never persists the hash policy is a gap: fossil opening the repository still reads auto. - New rows for 4.7.9, 6.2 (absent Z, last byte), 6.3.4 and 7.1. - The audit refuses the #0 placeholder, a test keeps the gap map and the fuzz test's forgiven losses tied to live gap cases, and one Z-card helper replaces two.
* test(conformance): mark artifact gaps with their issues (#263)
Replace the placeholder gap marks with the issues that track each fix (#265-#275), and keep coverage_test.go within 100 columns. (user: [email protected], size: 17985) [annotate] [blame] [check-ins using]
package conformance import ( "crypto/md5" "encoding/hex" "fmt" "path/filepath" "regexp" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/delta" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // The requirements of the artifact draft that are not about parsing one // artifact: naming, the comparator, the writer, the R card, the hash policy // and delta manifests. Each case calls the package that implements the // requirement. fossil(1) is not consulted; where a case encodes fossil's // behavior rather than plain draft text, its comment says so. // apiCase is a check of one requirement through a go-libfossil API. type apiCase struct { Case // run returns nil when go-libfossil conforms. t is for temporary // repositories only. run func(t *testing.T) error } func TestArtifactAPI(t *testing.T) { for _, c := range apiCases() { check(t, c.Case, func() error { return c.run(t) }) } } func apiCases() []apiCase { var all []apiCase for _, table := range [][]apiCase{ namingCases, writerCases, rCardCases, policyCases(), deltaManifestCases(), } { all = append(all, table...) } for i := range all { all[i].Gap = artifactGaps[all[i].ID] } return all } // The complete examples of Appendix A.1, byte for byte. const ( exampleSHA1 = "C initial\\sempty\\sbaseline\nD 2007-07-21T14:09:59\nP\n" + "R d41d8cd98f00b204e9800998ecf8427e\nU drh\nZ c62e3619f7c41ee77b33f00df8ec26f4\n" exampleSHA3 = "C Test\\stech-note\nD 2017-12-06T15:36:24.287\n" + "E 2013-08-26T07:35:00 bfdf42305400f6caa441fa816cb89231e90f8c82\n" + "P 94694585cd2f85f75b4316515bbb09aa7e9acaa5\nN text/x-fossil-wiki\n" + "T +bgcolor * #c0f0ff\nU drh\nW 415\nThis technote is only for test-purposes.\n\n" + "The original version of this technote had an empty C-card demonstrating that " + "the C-card in event artifacts are optional. Even when doing a \"fossil rebuild\" " + "using fossil version 1.22, this events displays fine. Of course, making the " + "Timeline Comment non-empty would be better. But nothing in Fossil prevents " + "leaving this line empty, so the C-card could as well be left out.\n\n" + "Z a7789c6da4c12b8feb6e290f3083f5f0\n" ) var namingCases = []apiCase{ {Case{ID: "artifact.3.1.name-covers-wrapper", Req: "AF-3.1-1"}, func(t *testing.T) error { r := newPolicyRepo(t, "2") signed := []byte(pgpHead + signedControl + pgpSig) _, uuid, err := blob.Store(r.DB(), signed) if err != nil { return err } return want("stored name", uuid, hash.SHA3(signed)) }}, {Case{ID: "artifact.3.2.sha1-example", Req: "AF-3.2-1"}, func(*testing.T) error { return want("SHA1(A.1.1)", hash.SHA1([]byte(exampleSHA1)), "a28c83647dfa805f05f3204a7e146eb1f0d90505") }}, {Case{ID: "artifact.3.2.sha3-example", Req: "AF-3.2-1"}, func(*testing.T) error { return want("SHA3(A.1.2)", hash.SHA3([]byte(exampleSHA3)), "2fa494a18a766efe9a8ba706d669b7157d5329e1fbcc2a6d6c15a386d113218d") }}, // FIPS 202 SHA3-256 of nothing; Keccak-256 would give c5d2460186f7... {Case{ID: "artifact.3.2.sha3-not-keccak", Req: "AF-3.2-1"}, func(*testing.T) error { return want("SHA3(empty)", hash.SHA3([]byte{}), "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a") }}, {Case{ID: "artifact.3.2.name-lengths", Req: "AF-3.2-2"}, func(*testing.T) error { for _, n := range []int{1, 39, 41, 63, 65} { if hash.IsValidHash(strings.Repeat("a", n)) { return expectf("a %d-digit name is valid", n) } } if !hash.IsValidHash(h1) || !hash.IsValidHash(h64) { return expectf("a 40- or 64-digit name is invalid") } return nil }}, {Case{ID: "artifact.3.3.lowercase-names", Req: "AF-3.3-1"}, func(*testing.T) error { lower := regexp.MustCompile(`^[0-9a-f]+$`) for _, name := range []string{hash.SHA1([]byte("x")), hash.SHA3([]byte("x"))} { if !lower.MatchString(name) { return expectf("computed name %q is not lowercase hex", name) } } return nil }}, {Case{ID: "artifact.3.4.verify-match", Req: "AF-3.4-1"}, verifyCase(hash.SHA3, true)}, {Case{ID: "artifact.3.4.verify-uppercase", Req: "AF-3.4-1"}, verifyCase( func(b []byte) string { return strings.ToUpper(hash.SHA1(b)) }, false)}, {Case{ID: "artifact.3.4.verify-length", Req: "AF-3.4-1"}, verifyCase( func(b []byte) string { return hash.SHA3(b)[:50] }, false)}, {Case{ID: "artifact.3.4.verify-mismatch", Req: "AF-3.4-1"}, verifyCase( func([]byte) string { return hash.SHA1([]byte("other")) }, false)}, {Case{ID: "artifact.4.5.3.comparator", Req: "AF-4.5.3-1"}, func(*testing.T) error { // Each pair is in ascending order under the canonical comparator. for _, pair := range [][2]string{ {"", "a"}, {"a", "ab"}, {"B", "a"}, {"a.b", "a/b"}, {"a/b", "a0"}, {"\x7f", "\x80"}, {"e\u0301", "\u00e9"}, {"Z", "\xff"}, } { if deck.Compare(pair[0], pair[1]) >= 0 || deck.Compare(pair[1], pair[0]) <= 0 { return expectf("Compare does not order %q before %q", pair[0], pair[1]) } } return nil }}, } // verifyZAccepts checks that deck.VerifyZ lets data through: it has no Z // card the verifier could find mismatched. func verifyZAccepts(data string) func(*testing.T) error { return func(*testing.T) error { if err := deck.VerifyZ([]byte(data)); err != nil { return expectf("deck.VerifyZ rejected %q: %v", data, err) } return nil } } // verifyCase stores "content" under the name name gives it and checks // whether the content then verifies, as Section 3.4 decides. func verifyCase(name func([]byte) string, verifies bool) func(*testing.T) error { return func(t *testing.T) error { r := newPolicyRepo(t, "2") data := []byte("content") compressed, err := blob.Compress(data) if err != nil { return err } result, err := r.DB().Exec("INSERT INTO blob(uuid, size, content, rcvid) VALUES(?,?,?,1)", name(data), len(data), compressed) if err != nil { return err } rid, err := result.LastInsertId() if err != nil { return err } err = content.Verify(r.DB(), libfossil.FslID(rid)) if verifies && err != nil { return expectf("content under its own name does not verify: %v", err) } if !verifies && err == nil { return expectf("content verifies under the name %q", name(data)) } return nil } } var writerCases = []apiCase{ {Case{ID: "artifact.4.6.1.writer-backslash", Req: "AF-4.6.1-3"}, writerRefuses("a\\b.txt")}, {Case{ID: "artifact.4.6.1.writer-invalid-utf8", Req: "AF-4.6.1-3"}, writerRefuses("a\xff.txt")}, {Case{ID: "artifact.4.6.1.writer-surrogate", Req: "AF-4.6.1-3"}, writerRefuses("a\xed\xa0\x80.txt")}, {Case{ID: "artifact.4.6.4.writer-form", Req: "AF-4.6.4-1"}, func(*testing.T) error { at := time.Date(2026, 1, 2, 3, 4, 5, 500e6, time.UTC) d := &deck.Deck{D: at, E: &deck.EventCard{Date: at, UUID: h1}, T: []deck.TagCard{{Type: deck.TagSingleton, Name: "x", UUID: "*"}}, U: deck.User("u"), W: []byte("w")} data, err := d.Marshal() if err != nil { return err } form := regexp.MustCompile(`(?m)^[DE] \d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?( |$)`) if n := len(form.FindAll(data, -1)); n != 2 { return expectf("%d of the D and E timestamps in %q have the writer form", n, data) } return nil }}, {Case{ID: "artifact.4.7.6.writer-permission", Req: "AF-4.7.6-3"}, func(*testing.T) error { d := &deck.Deck{D: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), F: []deck.FileCard{{Name: "new.txt", UUID: h1, OldName: "old.txt"}}} data, err := d.Marshal() if err != nil { return err } if line := "F new.txt " + h1 + " w old.txt\n"; !strings.Contains(string(data), line) { return expectf("Marshal wrote %q, want the line %q", data, line) } return nil }}, {Case{ID: "artifact.6.2.verify-missing-z", Req: "AF-6.2-5"}, verifyZAccepts(baseControl)}, {Case{ID: "artifact.6.2.verify-too-short", Req: "AF-6.2-5"}, verifyZAccepts("D x\n")}, // The last byte is not LF; Z verification must not look at it. {Case{ID: "artifact.6.2.verify-last-byte", Req: "AF-6.2-6"}, verifyZAccepts(strings.TrimSuffix(signedControl, "\n") + "x")}, {Case{ID: "artifact.6.2.writer-z", Req: "AF-6.2-4"}, func(*testing.T) error { d, err := deck.Parse(withZ(baseControl)) if err != nil { return err } data, err := d.Marshal() if err != nil { return err } // zCard renders the digest in lowercase, as Section 6.2 requires. return want("Z card", string(data[len(data)-35:]), zCard(string(data[:len(data)-35]))) }}, } // writerRefuses checks that a check-in refuses to create the filename. func writerRefuses(name string) func(*testing.T) error { return func(t *testing.T) error { r := newPolicyRepo(t, "2") _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: name, Content: []byte("x\n")}}, Comment: "c", User: "u", Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), }) if err == nil { return expectf("Checkin created the filename %q", name) } return nil } } // rCard is the R value of files, given as name and content pairs already in // comparator order, computed straight from Section 6.3.2. func rCard(files ...string) string { sum := md5.New() for i := 0; i < len(files); i += 2 { fmt.Fprintf(sum, "%s %d\n%s", files[i], len(files[i+1]), files[i+1]) } return hex.EncodeToString(sum.Sum(nil)) } // computeR runs deck.ComputeR over files given as name and content pairs. func computeR(files ...string) (string, error) { d := &deck.Deck{} byName := map[string]string{} for i := 0; i < len(files); i += 2 { name := hash.SHA1([]byte(files[i+1])) d.F = append(d.F, deck.FileCard{Name: files[i], UUID: name}) byName[name] = files[i+1] } return d.ComputeR(func(uuid string) ([]byte, error) { return []byte(byName[uuid]), nil }) } var rCardCases = []apiCase{ {Case{ID: "artifact.6.3.2.example", Req: "AF-6.3-2"}, func(*testing.T) error { got, err := computeR("alpha.txt", "hello world\n", "beta.txt", "fossil artifact format\n", "gamma.txt", "R card test\n") if err != nil { return err } return want("R of Appendix A.3", got, "95feb799536dc053e7968efb7cdd3ec3") }}, {Case{ID: "artifact.6.3.2.empty-tree", Req: "AF-6.3-2"}, func(*testing.T) error { got, err := computeR() if err != nil { return err } return want("R of no files", got, "d41d8cd98f00b204e9800998ecf8427e") }}, // Given out of order: "a.b" sorts before "a/b" because '.' < '/'. {Case{ID: "artifact.6.3.2.byte-order", Req: "AF-6.3-2"}, func(*testing.T) error { got, err := computeR("a/b", "two\n", "a.b", "one\n") if err != nil { return err } return want("R", got, rCard("a.b", "one\n", "a/b", "two\n")) }}, // Permissions are not part of the R input. {Case{ID: "artifact.6.3.2.permission-only", Req: "AF-6.3-2"}, func(*testing.T) error { d := &deck.Deck{F: []deck.FileCard{{Name: "run.sh", UUID: h1, Perm: "x"}, {Name: "link", UUID: h2, Perm: "l"}}} got, err := d.ComputeR(func(uuid string) ([]byte, error) { return []byte(uuid[:4]), nil }) if err != nil { return err } return want("R", got, rCard("link", h2[:4], "run.sh", h1[:4])) }}, {Case{ID: "artifact.6.3.4.writer-r", Req: "AF-6.3-4"}, func(t *testing.T) error { d, err := deltaCheckin(newPolicyRepo(t, "2")) if err != nil { return err } data, err := d.Marshal() if err != nil { return err } if !regexp.MustCompile(`(?m)^R [0-9a-f]{32}$`).Match(data) { return expectf("no R card of the writer form in %q", data) } return nil }}, {Case{ID: "artifact.6.3.3.delta-r", Req: "AF-6.3-3"}, func(t *testing.T) error { r := newPolicyRepo(t, "2") d, err := deltaCheckin(r) if err != nil { return err } if d.B == "" { return expectf("the second check-in is not a delta manifest") } return want("delta R", d.R, rCard("a.txt", "a2\n", "b.txt", "b\n", "c.txt", "c\n")) }}, } // deltaCheckin commits three files, then a delta check-in that changes one, // and returns the second manifest. func deltaCheckin(r *repo.Repo) (*deck.Deck, error) { at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) files := []manifest.File{{Name: "a.txt", Content: []byte("a\n")}, {Name: "b.txt", Content: []byte("b\n")}, {Name: "c.txt", Content: []byte("c\n")}} first, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: files, Comment: "one", User: "u", Time: at}) if err != nil { return nil, err } files[0].Content = []byte("a2\n") second, _, err := manifest.Checkin(r, manifest.CheckinOpts{Files: files, Comment: "two", User: "u", Time: at.Add(time.Minute), Parent: first, Delta: true}) if err != nil { return nil, err } return manifest.GetManifest(r, second) } // newPolicyRepo creates a repository with the given hash-policy value, or // with no policy row at all when policy is empty. func newPolicyRepo(t *testing.T, policy string) *repo.Repo { t.Helper() path := filepath.Join(t.TempDir(), "conformance.fossil") r, err := repo.Create(path, "tester", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } t.Cleanup(func() { r.Close() }) setPolicy(t, r, policy) return r } func setPolicy(t *testing.T, r *repo.Repo, policy string) { t.Helper() var err error if policy == "" { _, err = r.DB().Exec("DELETE FROM config WHERE name='hash-policy'") } else { err = r.SetConfig("hash-policy", policy) } if err != nil { t.Fatalf("setting hash-policy %q: %v", policy, err) } } // storeUnder stores data in a repository under policy, then moves the // repository to next ("" removes the setting), and returns data's name. func storeUnder(t *testing.T, policy, next string, data []byte) (*repo.Repo, libfossil.FslID, string) { t.Helper() r := newPolicyRepo(t, policy) rid, uuid, err := blob.Store(r.DB(), data) if err != nil { t.Fatalf("blob.Store: %v", err) } setPolicy(t, r, next) return r, rid, uuid } // namesWith checks the length of the name blob.Store gives new content. func namesWith(r *repo.Repo, length int) error { _, uuid, err := blob.Store(r.DB(), []byte("new content")) if err != nil { return err } return want("length of the new name", len(uuid), length) } func policyCases() []apiCase { var cases []apiCase // Policy values 0-4 are sha1, auto, sha3, sha3-only and shun-sha1. for policy, length := range []int{40, 40, 64, 64, 64} { cases = append(cases, apiCase{ Case{ID: fmt.Sprintf("artifact.7.1.policy-%d-primary", policy), Req: "AF-7.1-1"}, func(t *testing.T) error { return namesWith(newPolicyRepo(t, fmt.Sprint(policy)), length) }, }) } return append(cases, policyStateCases...) } var policyStateCases = []apiCase{ {Case{ID: "artifact.7.2.default-empty", Req: "AF-7.2-1"}, func(t *testing.T) error { return namesWith(newPolicyRepo(t, ""), 64) }}, {Case{ID: "artifact.7.2.default-sha1-repo", Req: "AF-7.2-1"}, func(t *testing.T) error { r, _, _ := storeUnder(t, "0", "", []byte("old")) return namesWith(r, 40) }}, {Case{ID: "artifact.7.2.default-sha3-repo", Req: "AF-7.2-1"}, func(t *testing.T) error { r, _, _ := storeUnder(t, "2", "", []byte("old")) return namesWith(r, 64) }}, {Case{ID: "artifact.7.4.ratchet-naming", Req: "AF-7.4-1"}, func(t *testing.T) error { r, err := ratchet(t) if err != nil { return err } return namesWith(r, 64) }}, {Case{ID: "artifact.7.4.ratchet-persisted", Req: "AF-7.4-1"}, func(t *testing.T) error { r, err := ratchet(t) if err != nil { return err } value, err := r.Config("hash-policy") if err != nil { return err } return want("hash-policy after the ratchet", value, "2") }}, {Case{ID: "artifact.7.5.sha3-keeps-sha1-name", Req: "AF-7.5-1"}, keepsName("0", "2")}, {Case{ID: "artifact.7.5.sha1-keeps-sha3-name", Req: "AF-7.5-1"}, keepsName("2", "0")}, {Case{ID: "artifact.7.5.sha3-only-separate-name", Req: "AF-7.5-2"}, separateName("3")}, {Case{ID: "artifact.7.5.shun-sha1-separate-name", Req: "AF-7.5-2"}, separateName("4")}, {Case{ID: "artifact.7.6.no-rename", Req: "AF-7.6-1"}, func(t *testing.T) error { r, rid, uuid := storeUnder(t, "0", "2", []byte("old")) got, err := r.UUIDByRID(int64(rid)) if err != nil { return err } return want("name after the policy change", got, uuid) }}, } // ratchet stores, under auto, an artifact whose supplied name is a SHA3 // one, the way a sync receive does. func ratchet(t *testing.T) (*repo.Repo, error) { r := newPolicyRepo(t, "1") source := []byte("source content\n") srcRid, _, err := blob.Store(r.DB(), source) if err != nil { return nil, err } target := []byte("source content\nand more\n") if _, err := blob.StoreDeltaRaw(r.DB(), hash.SHA3(target), delta.Create(source, target), srcRid, nil); err != nil { return nil, err } return r, nil } // keepsName stores content under one policy, then again under another that // may consult the first's algorithm, and wants the first name back. func keepsName(first, second string) func(*testing.T) error { return func(t *testing.T) error { data := []byte("shared content") r, rid, uuid := storeUnder(t, first, second, data) again, name, err := blob.Store(r.DB(), data) if err != nil { return err } return want("second store", fmt.Sprint(again, " ", name), fmt.Sprint(rid, " ", uuid)) } } // separateName stores content under sha1, then again under a policy that // disallows the alternative hash, and wants a second, SHA3 name. func separateName(strict string) func(*testing.T) error { return func(t *testing.T) error { data := []byte("shared content") r, _, _ := storeUnder(t, "0", strict, data) _, name, err := blob.Store(r.DB(), data) if err != nil { return err } return want("second name", name, hash.SHA3(data)) } }