Artifact b8d88703b9e627babd820e1b7098e588fe146fc30c45129c89ce5cafe1229f0e:
- File
conformance/artifact_api_test.go
— part of check-in
[38eddf43f1]
at
2026-10-11 23:34:08
on branch main
— fix(delta): refuse a source or target of 2^32 bytes or more in Create (#279) (#320)
The delta format holds integers below 2^32 only, so a producer must not write a target length or copy offset at or past that. Create now returns ErrTooLarge for such inputs; Deltify then stores the content whole, as fossil does when a delta is not worthwhile, and the other callers propagate the error. Apply's header check becomes >= so it refuses exactly what Create will not write. The bound is tested on lengths, as a direct test needs a 4 GiB input. (user: [email protected], size: 18034) [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") d, err := delta.Create(source, target) if err != nil { return nil, err } if _, err := blob.StoreDeltaRaw(r.DB(), hash.SHA3(target), d, 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)) } }