From a948b10bc80f44936153e6cdffb022de7a9a4b3f Mon Sep 17 00:00:00 2001 From: lczyk Date: Mon, 6 Jul 2026 22:58:45 +0100 Subject: [PATCH 01/30] feat: add sha256 --- internal/archive/archive.go | 48 ++++++++++++++++++--- internal/archive/archive_test.go | 32 ++++++++++++++ internal/archive/testarchive/testarchive.go | 31 ++++++++++--- internal/cache/cache.go | 6 ++- 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 360a35b73..d72f83317 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -136,8 +136,9 @@ func (a *ubuntuArchive) Fetch(pkg string) (io.ReadSeekCloser, *PackageInfo, erro return nil, nil, err } path := section.Get("Filename") + digest, digestKind := packageDigest(section) logf("Fetching %s...", path) - reader, err := index.fetch(path, section.Get("SHA256"), fetchBulk) + reader, err := index.fetch(path, digest, digestKind, fetchBulk) if err != nil { return nil, nil, err } @@ -277,7 +278,7 @@ func openUbuntu(options *Options) (Archive, error) { func (index *ubuntuIndex) fetchRelease() error { logf("Fetching %s %s %s suite details...", index.displayName(), index.version, index.suite) - reader, err := index.fetch(index.distPath("InRelease"), "", fetchDefault) + reader, err := index.fetch(index.distPath("InRelease"), "", cache.SHA256, fetchDefault) if err != nil { return err } @@ -325,16 +326,50 @@ func (index *ubuntuIndex) fetchRelease() error { return nil } +// digestFields lists the archive checksum fields chisel can verify, in order of +// preference. SHA256 stays first so existing archives keep their cache keys; +// Ubuntu 26.10 and later publish SHA512-only indices, handled by the fallback. +var digestFields = []struct { + name string + kind cache.DigestKind +}{ + {"SHA256", cache.SHA256}, + {"SHA512", cache.SHA512}, +} + +// releaseDigest returns the checksum recorded for path in a Release " +// " table, along with the hash kind it came from. +func releaseDigest(release control.Section, path string) (digest string, kind cache.DigestKind) { + for _, f := range digestFields { + if d, _, _ := control.ParsePathInfo(release.Get(f.name), path); d != "" { + return d, f.kind + } + } + return "", "" +} + +// packageDigest returns a package's own checksum from its Packages stanza, along +// with the hash kind it came from. +func packageDigest(section control.Section) (digest string, kind cache.DigestKind) { + for _, f := range digestFields { + if d := section.Get(f.name); d != "" { + return d, f.kind + } + } + // No digest advertised; the package is fetched unverified and cached under + // its computed SHA256, matching the previous behavior. + return "", cache.SHA256 +} + func (index *ubuntuIndex) fetchIndex() error { - digests := index.release.Get("SHA256") packagesPath := fmt.Sprintf("%s/binary-%s/Packages", index.component, index.arch) - digest, _, _ := control.ParsePathInfo(digests, packagesPath) + digest, digestKind := releaseDigest(index.release, packagesPath) if digest == "" { return fmt.Errorf("%s is missing from %s %s component digests", packagesPath, index.suite, index.component) } logf("Fetching index for %s %s %s %s component...", index.displayName(), index.version, index.suite, index.component) - reader, err := index.fetch(index.distPath(packagesPath+".gz"), digest, fetchBulk) + reader, err := index.fetch(index.distPath(packagesPath+".gz"), digest, digestKind, fetchBulk) if err != nil { return err } @@ -373,8 +408,7 @@ func (index *ubuntuIndex) distPath(suffix string) string { return "dists/" + index.suite + "/" + suffix } -func (index *ubuntuIndex) fetch(path, digest string, flags fetchFlags) (io.ReadSeekCloser, error) { - const digestKind = cache.SHA256 +func (index *ubuntuIndex) fetch(path, digest string, digestKind cache.DigestKind, flags fetchFlags) (io.ReadSeekCloser, error) { reader, err := index.archive.cache.Open(digestKind, digest) if err == nil { return reader, nil diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 8ca840e59..4c9133b4d 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -252,6 +252,38 @@ func (s *httpSuite) TestFetchPackage(c *C) { c.Assert(read(pkg), Equals, "mypkg4 1.4 data") } +func (s *httpSuite) TestFetchSHA512Digests(c *C) { + // Ubuntu 26.10+ publishes SHA512-only indices (no SHA256 section), so both + // the index digest and the package digest must be read from SHA512. + s.prepareArchiveAdjustRelease("questing", "25.10", "amd64", []string{"main", "universe"}, + func(release *testarchive.Release) { + release.Digest = "SHA512" + release.Walk(func(item testarchive.Item) error { + if p, ok := item.(*testarchive.Package); ok { + p.Digest = "SHA512" + } + return nil + }) + }) + + options := archive.Options{ + Label: "ubuntu", + Version: "25.10", + Arch: "amd64", + Suites: []string{"questing"}, + Components: []string{"main", "universe"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + pkg, _, err := testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + c.Assert(read(pkg), Equals, "mypkg1 1.1 data") +} + func (s *httpSuite) TestFetchPortsPackage(c *C) { s.base = "http://ports.ubuntu.com/ubuntu-ports/" diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index ea5b8e086..3789f759f 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "crypto/sha256" + "crypto/sha512" "fmt" "path" "strings" @@ -63,6 +64,8 @@ type Package struct { Arch string Component string Data []byte + // Digest names the checksum field published for this package ("SHA256" when empty). + Digest string } func (p *Package) Path() string { @@ -86,11 +89,11 @@ func (p *Package) Section() []byte { Installed-Size: 10 Filename: %s Size: %d - SHA256: %s + %s: %s Description: Description of %s Task: minimal - `)), p.Name, p.Arch, p.Version, p.Path(), len(content), makeSha256(content), p.Name) + `)), p.Name, p.Arch, p.Version, p.Path(), len(content), digestField(p.Digest), makeDigest(p.Digest, content), p.Name) return []byte(section) } @@ -107,6 +110,8 @@ type Release struct { Label string Items []Item PrivKey *packet.PrivateKey + // Digest names the checksum field published for the index table ("SHA256" when empty). + Digest string } func (r *Release) Walk(f func(Item) error) error { @@ -125,7 +130,7 @@ func (r *Release) Content() []byte { digests := bytes.Buffer{} for _, item := range r.Items { content := item.Content() - fmt.Fprintf(&digests, " %s %d %s\n", makeSha256(content), len(content), item.Path()) + fmt.Fprintf(&digests, " %s %d %s\n", makeDigest(r.Digest, content), len(content), item.Path()) } content := fmt.Sprintf(string(testutil.Reindent(` Origin: Ubuntu @@ -137,9 +142,9 @@ func (r *Release) Content() []byte { Architectures: amd64 arm64 armhf i386 ppc64el riscv64 s390x Components: main restricted universe multiverse Description: Ubuntu %s - SHA256: + %s: %s - `)), r.Label, r.Suite, r.Version, r.Version, digests.String()) + `)), r.Label, r.Suite, r.Version, r.Version, digestField(r.Digest), digests.String()) var buf bytes.Buffer writer, err := clearsign.Encode(&buf, r.PrivKey, nil) @@ -204,6 +209,22 @@ func makeSha256(b []byte) string { return fmt.Sprintf("%x", sha256.Sum256(b)) } +// digestField maps a digest kind to its Release/Packages field name, defaulting +// to SHA256. +func digestField(kind string) string { + if kind == "" { + return "SHA256" + } + return kind +} + +func makeDigest(kind string, b []byte) string { + if kind == "SHA512" { + return fmt.Sprintf("%x", sha512.Sum512(b)) + } + return makeSha256(b) +} + func makeGzip(b []byte) []byte { var buf bytes.Buffer gz := gzip.NewWriter(&buf) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index aedc2df0f..3b5621ecc 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -2,6 +2,7 @@ package cache import ( "crypto/sha256" + "crypto/sha512" "encoding/hex" "fmt" "hash" @@ -96,9 +97,10 @@ type DigestKind string const ( SHA256 DigestKind = "sha256" SHA384 DigestKind = "sha384" + SHA512 DigestKind = "sha512" ) -var digestKinds = []DigestKind{SHA256, SHA384} +var digestKinds = []DigestKind{SHA256, SHA384, SHA512} var ErrMiss = fmt.Errorf("not cached") @@ -117,6 +119,8 @@ func (c *Cache) Create(digestKind DigestKind, digest string) *Writer { h = sha256.New() case SHA384: h = sha3.New384() + case SHA512: + h = sha512.New() default: return &Writer{err: fmt.Errorf("internal error: unsupported digest kind: %q", digestKind)} } From e1b7e40e9c87708ed1bbc0ab8bb934fe85d10494 Mon Sep 17 00:00:00 2001 From: lczyk Date: Mon, 6 Jul 2026 23:07:38 +0100 Subject: [PATCH 02/30] docs: comment --- internal/archive/archive.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index d72f83317..9196fb802 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -278,6 +278,8 @@ func openUbuntu(options *Options) (Archive, error) { func (index *ubuntuIndex) fetchRelease() error { logf("Fetching %s %s %s suite details...", index.displayName(), index.version, index.suite) + // InRelease has no digest to check against (it is verified by its PGP + // signature below), so the digest kind here is arbitrary. reader, err := index.fetch(index.distPath("InRelease"), "", cache.SHA256, fetchDefault) if err != nil { return err From daf58642fc1de7936e308b56b3946fcd1a75adea Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Tue, 7 Jul 2026 09:21:37 +0200 Subject: [PATCH 03/30] tests: test SHA512 cache entries Signed-off-by: Paul Mars --- internal/cache/cache_test.go | 57 +++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 100f02579..7267a2e85 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -179,10 +179,37 @@ func (s *S) TestCacheSHA384(c *C) { c.Assert(err, Equals, cache.ErrMiss) } -func (s *S) TestCacheExpireBothDigestKinds(c *C) { +func (s *S) TestCacheSHA512(c *C) { cc := cache.Cache{Dir: c.MkDir()} - // Write entries under both digest kinds. + w := cc.Create(cache.SHA512, "") + _, err := w.Write([]byte("data1")) + c.Assert(err, IsNil) + err = w.Close() + c.Assert(err, IsNil) + sha512Digest := w.Digest() + + c.Assert(sha512Digest, Not(Equals), data1Digest) + + data, err := cc.Read(cache.SHA512, sha512Digest) + c.Assert(err, IsNil) + c.Assert(string(data), Equals, "data1") + + // SHA512 entry must not be visible under SHA256. + _, err = cc.Open(cache.SHA256, sha512Digest) + c.Assert(err, Equals, cache.ErrMiss) + + // SHA256 entry must not be visible under SHA512. + err = cc.Write(cache.SHA256, data1Digest, []byte("data1")) + c.Assert(err, IsNil) + _, err = cc.Open(cache.SHA512, data1Digest) + c.Assert(err, Equals, cache.ErrMiss) +} + +func (s *S) TestCacheExpireAllDigestKinds(c *C) { + cc := cache.Cache{Dir: c.MkDir()} + + // Write entries under all digest kinds. err := cc.Write(cache.SHA256, data1Digest, []byte("data1")) c.Assert(err, IsNil) err = cc.Write(cache.SHA256, data2Digest, []byte("data2")) @@ -202,10 +229,26 @@ func (s *S) TestCacheExpireBothDigestKinds(c *C) { c.Assert(err, IsNil) sha384Digest2 := w.Digest() + w = cc.Create(cache.SHA512, "") + _, err = w.Write([]byte("sha512data1")) + c.Assert(err, IsNil) + err = w.Close() + c.Assert(err, IsNil) + sha512Digest1 := w.Digest() + + w = cc.Create(cache.SHA512, "") + _, err = w.Write([]byte("sha512data2")) + c.Assert(err, IsNil) + err = w.Close() + c.Assert(err, IsNil) + sha512Digest2 := w.Digest() + sha256Expired := filepath.Join(cc.Dir, "sha256", data1Digest) sha256Fresh := filepath.Join(cc.Dir, "sha256", data2Digest) sha384Expired := filepath.Join(cc.Dir, "sha384", sha384Digest1) sha384Fresh := filepath.Join(cc.Dir, "sha384", sha384Digest2) + sha512Expired := filepath.Join(cc.Dir, "sha512", sha512Digest1) + sha512Fresh := filepath.Join(cc.Dir, "sha512", sha512Digest2) // Mark one entry per digest kind as expired. now := time.Now() @@ -214,21 +257,27 @@ func (s *S) TestCacheExpireBothDigestKinds(c *C) { c.Assert(err, IsNil) err = os.Chtimes(sha384Expired, now, expiredTime) c.Assert(err, IsNil) + err = os.Chtimes(sha512Expired, now, expiredTime) + c.Assert(err, IsNil) err = cc.Expire(time.Hour) c.Assert(err, IsNil) - // Expired entries must be removed from both digest kind directories. + // Expired entries must be removed from all digest kind directories. _, err = os.Stat(sha256Expired) c.Assert(os.IsNotExist(err), Equals, true) _, err = os.Stat(sha384Expired) c.Assert(os.IsNotExist(err), Equals, true) + _, err = os.Stat(sha512Expired) + c.Assert(os.IsNotExist(err), Equals, true) - // Fresh entries must remain in both digest kind directories. + // Fresh entries must remain in all digest kind directories. _, err = os.Stat(sha256Fresh) c.Assert(err, IsNil) _, err = os.Stat(sha384Fresh) c.Assert(err, IsNil) + _, err = os.Stat(sha512Fresh) + c.Assert(err, IsNil) } func (s *S) TestCacheExpireMissingDigestKindDir(c *C) { From 46d5658aa7c095c37e946e891b34b7325c8b85d8 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Tue, 7 Jul 2026 09:22:37 +0200 Subject: [PATCH 04/30] fix: refine comments Signed-off-by: Paul Mars --- internal/archive/archive.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 9196fb802..a60a071a3 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -328,8 +328,8 @@ func (index *ubuntuIndex) fetchRelease() error { return nil } -// digestFields lists the archive checksum fields chisel can verify, in order of -// preference. SHA256 stays first so existing archives keep their cache keys; +// digestFields lists the archive checksum fields Chisel can verify, in order of +// preference. SHA256 is first so existing archives keep their cache keys; // Ubuntu 26.10 and later publish SHA512-only indices, handled by the fallback. var digestFields = []struct { name string @@ -339,8 +339,6 @@ var digestFields = []struct { {"SHA512", cache.SHA512}, } -// releaseDigest returns the checksum recorded for path in a Release " -// " table, along with the hash kind it came from. func releaseDigest(release control.Section, path string) (digest string, kind cache.DigestKind) { for _, f := range digestFields { if d, _, _ := control.ParsePathInfo(release.Get(f.name), path); d != "" { @@ -350,16 +348,14 @@ func releaseDigest(release control.Section, path string) (digest string, kind ca return "", "" } -// packageDigest returns a package's own checksum from its Packages stanza, along -// with the hash kind it came from. func packageDigest(section control.Section) (digest string, kind cache.DigestKind) { for _, f := range digestFields { if d := section.Get(f.name); d != "" { return d, f.kind } } - // No digest advertised; the package is fetched unverified and cached under - // its computed SHA256, matching the previous behavior. + // No digest advertised; fall back to SHA256 so the package can still be + // cached and retrieved by its computed digest. return "", cache.SHA256 } From 1511187613b0b7c6decc31e22fa897e6eb0e5753 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Tue, 7 Jul 2026 09:23:12 +0200 Subject: [PATCH 05/30] tests: use stonking for SHA512 tests Signed-off-by: Paul Mars --- internal/archive/archive_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 4c9133b4d..ff7709a0f 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -255,7 +255,7 @@ func (s *httpSuite) TestFetchPackage(c *C) { func (s *httpSuite) TestFetchSHA512Digests(c *C) { // Ubuntu 26.10+ publishes SHA512-only indices (no SHA256 section), so both // the index digest and the package digest must be read from SHA512. - s.prepareArchiveAdjustRelease("questing", "25.10", "amd64", []string{"main", "universe"}, + s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { release.Digest = "SHA512" release.Walk(func(item testarchive.Item) error { @@ -270,7 +270,7 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { Label: "ubuntu", Version: "25.10", Arch: "amd64", - Suites: []string{"questing"}, + Suites: []string{"stonking"}, Components: []string{"main", "universe"}, CacheDir: c.MkDir(), PubKeys: []*packet.PublicKey{s.pubKey}, From bbdf2fe29fe698908b09902cde3c8160cd6b36b8 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Tue, 7 Jul 2026 09:24:01 +0200 Subject: [PATCH 06/30] fix: rely on ok to check digest Signed-off-by: Paul Mars --- internal/archive/archive.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index a60a071a3..f4510edee 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -341,7 +341,7 @@ var digestFields = []struct { func releaseDigest(release control.Section, path string) (digest string, kind cache.DigestKind) { for _, f := range digestFields { - if d, _, _ := control.ParsePathInfo(release.Get(f.name), path); d != "" { + if d, _, ok := control.ParsePathInfo(release.Get(f.name), path); ok { return d, f.kind } } From c973b904eb0003bcda7f16d02959c51ae36a9108 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 08:56:42 +0100 Subject: [PATCH 07/30] test: cover by-hash over sha512 --- internal/archive/archive_test.go | 46 +++++++++++++++++++++ internal/archive/testarchive/testarchive.go | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 449fe4273..0de9dfbc3 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -719,6 +719,52 @@ func (s *httpSuite) TestFetchByHashSucceedsWhenNamedPathIsStale(c *C) { c.Assert(status, Equals, 200) } +func (s *httpSuite) TestFetchByHashSHA512(c *C) { + // Ubuntu 26.10+ advertises Acquire-By-Hash with SHA512-only indices, so + // the by-hash URL must be built under the SHA512 directory. + s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(r *testarchive.Release) { + r.ByHash = true + r.Digest = "SHA512" + r.Walk(func(item testarchive.Item) error { + if p, ok := item.(*testarchive.Package); ok { + p.Digest = "SHA512" + } + return nil + }) + }) + + // Stale content at the named Packages.gz path, so a fallback would fail + // the digest check -- only the by-hash path serves the correct bytes. + for p := range s.responses { + if strings.Contains(p, "Packages.gz") && !strings.Contains(p, "/by-hash/") { + s.responses[p] = testarchive.MakeGzip([]byte("stale Packages from previous publication")) + } + } + + options := archive.Options{ + Label: "ubuntu", + Version: "26.10", + Arch: "amd64", + Suites: []string{"stonking"}, + Components: []string{"main"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + pkg, _, err := testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + c.Assert(read(pkg), Equals, "mypkg1 1.1 data") + + // The SHA512 by-hash request must have been attempted and succeeded; + // the named path only has stale content. + attempted, status := s.fetchRequestStatus("/by-hash/SHA512/") + c.Assert(attempted, Equals, true) + c.Assert(status, Equals, 200) +} + func (s *httpSuite) TestFetchByHashFallsBackOnNotFound(c *C) { s.prepareArchiveAdjustRelease("jammy", "22.04", "amd64", []string{"main"}, func(r *testarchive.Release) { r.ByHash = true diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 2a62908e2..41fbd2f79 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -180,7 +180,7 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent if r.ByHash && itemPath != r.Path() { - byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", "SHA256", makeSha256(itemContent)) + byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", digestField(r.Digest), makeDigest(r.Digest, itemContent)) content[byHashPath] = itemContent } return nil From 9fa6d5377442b4783b379904803c2103c8566a71 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 09:44:14 +0100 Subject: [PATCH 08/30] test: propagate digest kind to packages in test helper --- internal/archive/archive_test.go | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 0de9dfbc3..bf7328e4d 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -158,6 +158,16 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com if adjustRelease != nil { adjustRelease(release) } + // Packages inherit the release's digest kind unless they set their own. + err = release.Walk(func(item testarchive.Item) error { + if p, ok := item.(*testarchive.Package); ok && p.Digest == "" { + p.Digest = release.Digest + } + return nil + }) + if err != nil { + panic(err) + } release.Render(base.Path, s.responses) return release } @@ -270,12 +280,6 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { release.Digest = "SHA512" - release.Walk(func(item testarchive.Item) error { - if p, ok := item.(*testarchive.Package); ok { - p.Digest = "SHA512" - } - return nil - }) }) options := archive.Options{ @@ -722,15 +726,9 @@ func (s *httpSuite) TestFetchByHashSucceedsWhenNamedPathIsStale(c *C) { func (s *httpSuite) TestFetchByHashSHA512(c *C) { // Ubuntu 26.10+ advertises Acquire-By-Hash with SHA512-only indices, so // the by-hash URL must be built under the SHA512 directory. - s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(r *testarchive.Release) { - r.ByHash = true - r.Digest = "SHA512" - r.Walk(func(item testarchive.Item) error { - if p, ok := item.(*testarchive.Package); ok { - p.Digest = "SHA512" - } - return nil - }) + s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { + release.ByHash = true + release.Digest = "SHA512" }) // Stale content at the named Packages.gz path, so a fallback would fail From 46fefb3b2cbc83986e195044a5e549ec62101a8e Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 13:33:55 +0100 Subject: [PATCH 09/30] test: cover release publishing both digests Change the testarchive digest field from a single kind to a list so a package/release can publish several checksum fields at once. Add tests asserting SHA256 is preferred when both are published, for the named and by-hash paths. --- internal/archive/archive_test.go | 83 ++++++++++++++++++++- internal/archive/testarchive/testarchive.go | 48 ++++++++---- 2 files changed, 113 insertions(+), 18 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index bf7328e4d..80cecb35c 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -160,8 +160,8 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com } // Packages inherit the release's digest kind unless they set their own. err = release.Walk(func(item testarchive.Item) error { - if p, ok := item.(*testarchive.Package); ok && p.Digest == "" { - p.Digest = release.Digest + if p, ok := item.(*testarchive.Package); ok && len(p.Hashes) == 0 { + p.Hashes = release.Hashes } return nil }) @@ -279,7 +279,7 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { // the index digest and the package digest must be read from SHA512. s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { - release.Digest = "SHA512" + release.Hashes = []string{"SHA512"} }) options := archive.Options{ @@ -300,6 +300,39 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { c.Assert(read(pkg), Equals, "mypkg1 1.1 data") } +func (s *httpSuite) TestFetchBothDigests(c *C) { + // An archive publishing both SHA256 and SHA512 sections (index table and + // package fields) must be handled, with SHA256 preferred per digestFields + // ordering -- so PackageInfo.SHA256 is the field that surfaces. + s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, + func(release *testarchive.Release) { + release.Hashes = []string{"SHA256", "SHA512"} + }) + + options := archive.Options{ + Label: "ubuntu", + Version: "25.10", + Arch: "amd64", + Suites: []string{"stonking"}, + Components: []string{"main", "universe"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + pkg, info, err := testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + c.Assert(info, DeepEquals, &archive.PackageInfo{ + Name: "mypkg1", + Version: "1.1", + Arch: "amd64", + SHA256: "1f08ef04cfe7a8087ee38a1ea35fa1810246648136c3c42d5a61ad6503d85e05", + }) + c.Assert(read(pkg), Equals, "mypkg1 1.1 data") +} + func (s *httpSuite) TestFetchPortsPackage(c *C) { s.base = "http://ports.ubuntu.com/ubuntu-ports/" @@ -728,7 +761,7 @@ func (s *httpSuite) TestFetchByHashSHA512(c *C) { // the by-hash URL must be built under the SHA512 directory. s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { release.ByHash = true - release.Digest = "SHA512" + release.Hashes = []string{"SHA512"} }) // Stale content at the named Packages.gz path, so a fallback would fail @@ -763,6 +796,48 @@ func (s *httpSuite) TestFetchByHashSHA512(c *C) { c.Assert(status, Equals, 200) } +func (s *httpSuite) TestFetchByHashBothDigests(c *C) { + // When a by-hash archive publishes both digests, the by-hash URL must be + // built under SHA256 (preferred) and SHA512 must not be requested. + s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { + release.ByHash = true + release.Hashes = []string{"SHA256", "SHA512"} + }) + + // Stale content at the named Packages.gz path, so a fallback would fail + // the digest check -- only the by-hash path serves the correct bytes. + for p := range s.responses { + if strings.Contains(p, "Packages.gz") && !strings.Contains(p, "/by-hash/") { + s.responses[p] = testarchive.MakeGzip([]byte("stale Packages from previous publication")) + } + } + + options := archive.Options{ + Label: "ubuntu", + Version: "26.10", + Arch: "amd64", + Suites: []string{"stonking"}, + Components: []string{"main"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + pkg, _, err := testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + c.Assert(read(pkg), Equals, "mypkg1 1.1 data") + + // The SHA256 by-hash request must have been made and succeeded; the SHA512 + // by-hash directory must never be touched. + attempted, status := s.fetchRequestStatus("/by-hash/SHA256/") + c.Assert(attempted, Equals, true) + c.Assert(status, Equals, 200) + attempted, _ = s.fetchRequestStatus("/by-hash/SHA512/") + c.Assert(attempted, Equals, false) +} + func (s *httpSuite) TestFetchByHashFallsBackOnNotFound(c *C) { s.prepareArchiveAdjustRelease("jammy", "22.04", "amd64", []string{"main"}, func(r *testarchive.Release) { r.ByHash = true diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 41fbd2f79..3cdb9d991 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -64,8 +64,8 @@ type Package struct { Arch string Component string Data []byte - // Digest names the checksum field published for this package ("SHA256" when empty). - Digest string + // Hashes names the checksum fields published for this package (["SHA256"] when empty). + Hashes []string } func (p *Package) Path() string { @@ -78,6 +78,13 @@ func (p *Package) Walk(f func(Item) error) error { func (p *Package) Section() []byte { content := p.Content() + digests := strings.Builder{} + for i, kind := range hashKinds(p.Hashes) { + if i > 0 { + digests.WriteByte('\n') + } + fmt.Fprintf(&digests, "%s: %s", digestField(kind), makeDigest(kind, content)) + } section := fmt.Sprintf(string(testutil.Reindent(` Package: %s Architecture: %s @@ -89,11 +96,11 @@ func (p *Package) Section() []byte { Installed-Size: 10 Filename: %s Size: %d - %s: %s + %s Description: Description of %s Task: minimal - `)), p.Name, p.Arch, p.Version, p.Path(), len(content), digestField(p.Digest), makeDigest(p.Digest, content), p.Name) + `)), p.Name, p.Arch, p.Version, p.Path(), len(content), digests.String(), p.Name) return []byte(section) } @@ -110,8 +117,8 @@ type Release struct { Label string Items []Item PrivKey *packet.PrivateKey - // Digest names the checksum field published for the index table ("SHA256" when empty). - Digest string + // Hashes names the checksum fields published for the index table (["SHA256"] when empty). + Hashes []string // ByHash enables the Acquire-By-Hash flag in the Release file // and renders by-hash URLs alongside named paths. ByHash bool @@ -131,9 +138,12 @@ func (r *Release) Section() []byte { func (r *Release) Content() []byte { digests := bytes.Buffer{} - for _, item := range r.Items { - content := item.Content() - fmt.Fprintf(&digests, " %s %d %s\n", makeDigest(r.Digest, content), len(content), item.Path()) + for _, kind := range hashKinds(r.Hashes) { + fmt.Fprintf(&digests, "%s:\n", digestField(kind)) + for _, item := range r.Items { + content := item.Content() + fmt.Fprintf(&digests, " %s %d %s\n", makeDigest(kind, content), len(content), item.Path()) + } } acquireByHash := "" if r.ByHash { @@ -149,9 +159,8 @@ func (r *Release) Content() []byte { Architectures: amd64 arm64 armhf i386 ppc64el riscv64 s390x Components: main restricted universe multiverse Description: Ubuntu %s - %s%s: - %s - `)), r.Label, r.Suite, r.Version, r.Version, acquireByHash, digestField(r.Digest), digests.String()) + %s%s + `)), r.Label, r.Suite, r.Version, r.Version, acquireByHash, digests.String()) var buf bytes.Buffer writer, err := clearsign.Encode(&buf, r.PrivKey, nil) @@ -180,8 +189,10 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent if r.ByHash && itemPath != r.Path() { - byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", digestField(r.Digest), makeDigest(r.Digest, itemContent)) - content[byHashPath] = itemContent + for _, kind := range hashKinds(r.Hashes) { + byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", digestField(kind), makeDigest(kind, itemContent)) + content[byHashPath] = itemContent + } } return nil }) @@ -221,6 +232,15 @@ func makeSha256(b []byte) string { return fmt.Sprintf("%x", sha256.Sum256(b)) } +// hashKinds returns the digest kinds to publish, defaulting to ["SHA256"] when +// none are set. +func hashKinds(hashes []string) []string { + if len(hashes) == 0 { + return []string{"SHA256"} + } + return hashes +} + // digestField maps a digest kind to its Release/Packages field name, defaulting // to SHA256. func digestField(kind string) string { From 1e1a0d14eeb7767bffbaf3e8a2ceddf4d48be183 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 13:44:31 +0100 Subject: [PATCH 10/30] refactor: rename digestField to digestSection Use "section" for the checksum groups, matching the control.Section terminology used across the archive package. --- internal/archive/archive.go | 38 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 4c27f4311..17b749404 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -331,36 +331,36 @@ func (index *ubuntuIndex) fetchRelease() error { return nil } -// digestField is an archive checksum field Chisel can verify. Its name doubles -// as the by-hash directory name in the archive layout. -type digestField struct { +// digestSection is an archive checksum section Chisel can verify. Its name +// doubles as the by-hash directory name in the archive layout. +type digestSection struct { name string kind cache.DigestKind } -// digestFields lists the checksum fields Chisel can verify, in order of +// digestSections lists the checksum sections Chisel can verify, in order of // preference. SHA256 is first so existing archives keep their cache keys; // Ubuntu 26.10 and later publish SHA512-only indices, handled by the fallback. -var digestFields = []digestField{ +var digestSections = []digestSection{ {"SHA256", cache.SHA256}, {"SHA512", cache.SHA512}, } // releaseDigest returns the checksum recorded for path in a Release " -// " table, along with the field it was found in. -func releaseDigest(release control.Section, path string) (digest string, field digestField) { - for _, f := range digestFields { - if d, _, ok := control.ParsePathInfo(release.Get(f.name), path); ok { - return d, f +// " table, along with the section it was found in. +func releaseDigest(release control.Section, path string) (digest string, section digestSection) { + for _, s := range digestSections { + if d, _, ok := control.ParsePathInfo(release.Get(s.name), path); ok { + return d, s } } - return "", digestField{} + return "", digestSection{} } func packageDigest(section control.Section) (digest string, kind cache.DigestKind) { - for _, f := range digestFields { - if d := section.Get(f.name); d != "" { - return d, f.kind + for _, s := range digestSections { + if d := section.Get(s.name); d != "" { + return d, s.kind } } // No digest advertised; fall back to SHA256 so the package can still be @@ -370,7 +370,7 @@ func packageDigest(section control.Section) (digest string, kind cache.DigestKin func (index *ubuntuIndex) fetchIndex() error { packagesPath := fmt.Sprintf("%s/binary-%s/Packages", index.component, index.arch) - packagesDigest, field := releaseDigest(index.release, packagesPath) + packagesDigest, section := releaseDigest(index.release, packagesPath) if packagesDigest == "" { return fmt.Errorf("%s is missing from %s %s component digests", packagesPath, index.suite, index.component) } @@ -384,10 +384,10 @@ func (index *ubuntuIndex) fetchIndex() error { packagesGzPath := packagesPath + ".gz" var reader io.ReadSeekCloser if index.release.Get("Acquire-By-Hash") == "yes" { - packagesGzDigest, _, _ := control.ParsePathInfo(index.release.Get(field.name), packagesGzPath) + packagesGzDigest, _, _ := control.ParsePathInfo(index.release.Get(section.name), packagesGzPath) if packagesGzDigest != "" { - packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, field.name, packagesGzDigest) - r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, field.kind, fetchBulk|fetchGzip) + packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, section.name, packagesGzDigest) + r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, section.kind, fetchBulk|fetchGzip) if err != nil && err != errNotFound { return err } @@ -397,7 +397,7 @@ func (index *ubuntuIndex) fetchIndex() error { } } if reader == nil { - r, err := index.fetch(index.distPath(packagesGzPath), packagesDigest, field.kind, fetchBulk|fetchGzip) + r, err := index.fetch(index.distPath(packagesGzPath), packagesDigest, section.kind, fetchBulk|fetchGzip) if err != nil { return err } From 58c0e97fb7c348d0533be6cf8b19c7e61dea0be9 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 13:57:23 +0100 Subject: [PATCH 11/30] test: inline makeSha256 Its only caller is makeDigest; fold the sha256 sum in and drop the one-line helper. --- internal/archive/testarchive/testarchive.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 3cdb9d991..3baa614bf 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -228,10 +228,6 @@ func (pi *PackageIndex) Content() []byte { return MergeSections(pi.Packages) } -func makeSha256(b []byte) string { - return fmt.Sprintf("%x", sha256.Sum256(b)) -} - // hashKinds returns the digest kinds to publish, defaulting to ["SHA256"] when // none are set. func hashKinds(hashes []string) []string { @@ -254,7 +250,7 @@ func makeDigest(kind string, b []byte) string { if kind == "SHA512" { return fmt.Sprintf("%x", sha512.Sum512(b)) } - return makeSha256(b) + return fmt.Sprintf("%x", sha256.Sum256(b)) } func MakeGzip(b []byte) []byte { From 82dd7e71432d51fe4070bb330fd6e2e2384ed188 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 13:59:31 +0100 Subject: [PATCH 12/30] test: make digest kinds an archive-wide property Digest kinds are a per-archive choice, not per-package, so model them on the Release alone. The package copy becomes an unexported render cache, filled from the release at render time, so tests can only set kinds in one place and the post-adjustRelease propagation walk goes away. --- internal/archive/archive_test.go | 22 ++++------- internal/archive/testarchive/testarchive.go | 43 +++++++++++++++------ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 80cecb35c..cd162b7b3 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -158,16 +158,8 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com if adjustRelease != nil { adjustRelease(release) } - // Packages inherit the release's digest kind unless they set their own. - err = release.Walk(func(item testarchive.Item) error { - if p, ok := item.(*testarchive.Package); ok && len(p.Hashes) == 0 { - p.Hashes = release.Hashes - } - return nil - }) - if err != nil { - panic(err) - } + // Packages inherit the release's digest kinds at render time (see + // Release.inheritDigestKinds). release.Render(base.Path, s.responses) return release } @@ -279,7 +271,7 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { // the index digest and the package digest must be read from SHA512. s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { - release.Hashes = []string{"SHA512"} + release.DigestKinds = []string{"SHA512"} }) options := archive.Options{ @@ -302,11 +294,11 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { func (s *httpSuite) TestFetchBothDigests(c *C) { // An archive publishing both SHA256 and SHA512 sections (index table and - // package fields) must be handled, with SHA256 preferred per digestFields + // package fields) must be handled, with SHA256 preferred per digestSections // ordering -- so PackageInfo.SHA256 is the field that surfaces. s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { - release.Hashes = []string{"SHA256", "SHA512"} + release.DigestKinds = []string{"SHA256", "SHA512"} }) options := archive.Options{ @@ -761,7 +753,7 @@ func (s *httpSuite) TestFetchByHashSHA512(c *C) { // the by-hash URL must be built under the SHA512 directory. s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { release.ByHash = true - release.Hashes = []string{"SHA512"} + release.DigestKinds = []string{"SHA512"} }) // Stale content at the named Packages.gz path, so a fallback would fail @@ -801,7 +793,7 @@ func (s *httpSuite) TestFetchByHashBothDigests(c *C) { // built under SHA256 (preferred) and SHA512 must not be requested. s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { release.ByHash = true - release.Hashes = []string{"SHA256", "SHA512"} + release.DigestKinds = []string{"SHA256", "SHA512"} }) // Stale content at the named Packages.gz path, so a fallback would fail diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 3baa614bf..c6036d089 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -64,8 +64,10 @@ type Package struct { Arch string Component string Data []byte - // Hashes names the checksum fields published for this package (["SHA256"] when empty). - Hashes []string + // digestKinds is filled from the release at render time (see + // inheritDigestKinds). Digest kinds are an archive-wide choice, so tests set + // them on the Release, not per package. + digestKinds []string } func (p *Package) Path() string { @@ -79,7 +81,7 @@ func (p *Package) Walk(f func(Item) error) error { func (p *Package) Section() []byte { content := p.Content() digests := strings.Builder{} - for i, kind := range hashKinds(p.Hashes) { + for i, kind := range resolveDigestKinds(p.digestKinds) { if i > 0 { digests.WriteByte('\n') } @@ -117,8 +119,9 @@ type Release struct { Label string Items []Item PrivKey *packet.PrivateKey - // Hashes names the checksum fields published for the index table (["SHA256"] when empty). - Hashes []string + // DigestKinds names the digest kinds published across this archive (["SHA256"] + // when empty): the index table and every package it publishes. + DigestKinds []string // ByHash enables the Acquire-By-Hash flag in the Release file // and renders by-hash URLs alongside named paths. ByHash bool @@ -136,9 +139,24 @@ func (r *Release) Section() []byte { return nil } +// inheritDigestKinds copies the release's digest kinds onto every package it +// publishes, so the archive-wide choice reaches each package section. Run at +// render time and idempotent, so it always sees the final release state. +func (r *Release) inheritDigestKinds() { + for _, item := range r.Items { + item.Walk(func(item Item) error { + if p, ok := item.(*Package); ok { + p.digestKinds = r.DigestKinds + } + return nil + }) + } +} + func (r *Release) Content() []byte { + r.inheritDigestKinds() digests := bytes.Buffer{} - for _, kind := range hashKinds(r.Hashes) { + for _, kind := range resolveDigestKinds(r.DigestKinds) { fmt.Fprintf(&digests, "%s:\n", digestField(kind)) for _, item := range r.Items { content := item.Content() @@ -179,6 +197,7 @@ func (r *Release) Content() []byte { } func (r *Release) Render(prefix string, content map[string][]byte) error { + r.inheritDigestKinds() return r.Walk(func(item Item) error { itemPath := item.Path() itemContent := item.Content() @@ -189,7 +208,7 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent if r.ByHash && itemPath != r.Path() { - for _, kind := range hashKinds(r.Hashes) { + for _, kind := range resolveDigestKinds(r.DigestKinds) { byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", digestField(kind), makeDigest(kind, itemContent)) content[byHashPath] = itemContent } @@ -228,13 +247,13 @@ func (pi *PackageIndex) Content() []byte { return MergeSections(pi.Packages) } -// hashKinds returns the digest kinds to publish, defaulting to ["SHA256"] when -// none are set. -func hashKinds(hashes []string) []string { - if len(hashes) == 0 { +// resolveDigestKinds returns the digest kinds to publish, defaulting to +// ["SHA256"] when none are set. +func resolveDigestKinds(kinds []string) []string { + if len(kinds) == 0 { return []string{"SHA256"} } - return hashes + return kinds } // digestField maps a digest kind to its Release/Packages field name, defaulting From f8eb1187c3235355df7c7ee3e74e3f4281152e66 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 16:20:07 +0100 Subject: [PATCH 13/30] test: drop implicit SHA256 default for digest kinds Digest kinds now render exactly as set on the Release: an empty list publishes no digest sections rather than silently falling back to SHA256. The single construction site sets the SHA256 default explicitly, so tests still get it without hidden behaviour. --- internal/archive/archive_test.go | 11 +++---- internal/archive/testarchive/testarchive.go | 35 ++++++--------------- 2 files changed, 14 insertions(+), 32 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index cd162b7b3..95cc448d6 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -129,10 +129,11 @@ func (s *httpSuite) prepareArchive(suite, version, arch string, components []str func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, components []string, adjustRelease func(*testarchive.Release)) *testarchive.Release { release := &testarchive.Release{ - Suite: suite, - Version: version, - Label: "Ubuntu", - PrivKey: s.privKey, + Suite: suite, + Version: version, + Label: "Ubuntu", + PrivKey: s.privKey, + DigestKinds: []string{"SHA256"}, } for i, component := range components { index := &testarchive.PackageIndex{ @@ -158,8 +159,6 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com if adjustRelease != nil { adjustRelease(release) } - // Packages inherit the release's digest kinds at render time (see - // Release.inheritDigestKinds). release.Render(base.Path, s.responses) return release } diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index c6036d089..048058004 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -81,11 +81,11 @@ func (p *Package) Walk(f func(Item) error) error { func (p *Package) Section() []byte { content := p.Content() digests := strings.Builder{} - for i, kind := range resolveDigestKinds(p.digestKinds) { + for i, kind := range p.digestKinds { if i > 0 { digests.WriteByte('\n') } - fmt.Fprintf(&digests, "%s: %s", digestField(kind), makeDigest(kind, content)) + fmt.Fprintf(&digests, "%s: %s", kind, makeDigest(kind, content)) } section := fmt.Sprintf(string(testutil.Reindent(` Package: %s @@ -119,8 +119,9 @@ type Release struct { Label string Items []Item PrivKey *packet.PrivateKey - // DigestKinds names the digest kinds published across this archive (["SHA256"] - // when empty): the index table and every package it publishes. + // DigestKinds names the digest kinds published across this archive: the + // index table and every package it publishes. Callers must set it + // explicitly; an empty list publishes no digest sections at all. DigestKinds []string // ByHash enables the Acquire-By-Hash flag in the Release file // and renders by-hash URLs alongside named paths. @@ -156,8 +157,8 @@ func (r *Release) inheritDigestKinds() { func (r *Release) Content() []byte { r.inheritDigestKinds() digests := bytes.Buffer{} - for _, kind := range resolveDigestKinds(r.DigestKinds) { - fmt.Fprintf(&digests, "%s:\n", digestField(kind)) + for _, kind := range r.DigestKinds { + fmt.Fprintf(&digests, "%s:\n", kind) for _, item := range r.Items { content := item.Content() fmt.Fprintf(&digests, " %s %d %s\n", makeDigest(kind, content), len(content), item.Path()) @@ -208,8 +209,8 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent if r.ByHash && itemPath != r.Path() { - for _, kind := range resolveDigestKinds(r.DigestKinds) { - byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", digestField(kind), makeDigest(kind, itemContent)) + for _, kind := range r.DigestKinds { + byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", kind, makeDigest(kind, itemContent)) content[byHashPath] = itemContent } } @@ -247,24 +248,6 @@ func (pi *PackageIndex) Content() []byte { return MergeSections(pi.Packages) } -// resolveDigestKinds returns the digest kinds to publish, defaulting to -// ["SHA256"] when none are set. -func resolveDigestKinds(kinds []string) []string { - if len(kinds) == 0 { - return []string{"SHA256"} - } - return kinds -} - -// digestField maps a digest kind to its Release/Packages field name, defaulting -// to SHA256. -func digestField(kind string) string { - if kind == "" { - return "SHA256" - } - return kind -} - func makeDigest(kind string, b []byte) string { if kind == "SHA512" { return fmt.Sprintf("%x", sha512.Sum512(b)) From bc4b7f61c35e5d7f48545b83810a8fc096223392 Mon Sep 17 00:00:00 2001 From: lczyk Date: Tue, 7 Jul 2026 16:23:05 +0100 Subject: [PATCH 14/30] fix: check Walk return in inheritDigestKinds golangci-lint errcheck flagged the unchecked item.Walk. The callback only sets a field and always returns nil, so Walk cannot error here; ignore the return explicitly to satisfy the linter. --- internal/archive/testarchive/testarchive.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 048058004..11998f684 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -145,7 +145,8 @@ func (r *Release) Section() []byte { // render time and idempotent, so it always sees the final release state. func (r *Release) inheritDigestKinds() { for _, item := range r.Items { - item.Walk(func(item Item) error { + // The callback never errors, so neither can Walk. + _ = item.Walk(func(item Item) error { if p, ok := item.(*Package); ok { p.digestKinds = r.DigestKinds } From c1794891771494f72b88a0e6f6d696f50b025302 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 10:02:28 +0100 Subject: [PATCH 15/30] fix: build by-hash URLs from strongest digest Archives only guarantee a by-hash directory for the strongest hash they advertise, so a by-hash URL must never be built from a weaker one. The digest used to verify content and key the cache is a separate concern, and still prefers SHA256 to keep existing caches valid. The test archive now publishes by-hash directories only for the strongest kind, matching ftpmaster, so building a by-hash URL from a weaker hash fails the tests instead of passing silently. --- internal/archive/archive.go | 39 +++++++++++++++------ internal/archive/archive_test.go | 16 ++++++--- internal/archive/testarchive/testarchive.go | 17 ++++++++- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 17b749404..5dfa2a879 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -338,18 +338,30 @@ type digestSection struct { kind cache.DigestKind } -// digestSections lists the checksum sections Chisel can verify, in order of -// preference. SHA256 is first so existing archives keep their cache keys; -// Ubuntu 26.10 and later publish SHA512-only indices, handled by the fallback. +// digestSections lists the checksum sections Chisel can verify, strongest +// first. This order matches the by-hash archive layout: an archive is only +// guaranteed to publish a by-hash directory for the strongest hash it +// advertises. var digestSections = []digestSection{ + {"SHA512", cache.SHA512}, + {"SHA256", cache.SHA256}, +} + +// cacheDigestSections is digestSections reordered to prefer SHA256, so that +// existing caches keep their keys. It picks the digest used to verify content +// and key the cache, and nothing else. +// TODO Drop this in favour of digestSections. That changes cache keys for +// archives publishing both digests, so it must wait for a minor release. +var cacheDigestSections = []digestSection{ {"SHA256", cache.SHA256}, {"SHA512", cache.SHA512}, } -// releaseDigest returns the checksum recorded for path in a Release " -// " table, along with the section it was found in. -func releaseDigest(release control.Section, path string) (digest string, section digestSection) { - for _, s := range digestSections { +// findDigest returns the checksum recorded for path in a Release " +// " table, along with the section it was found in, trying the +// sections in order. +func findDigest(release control.Section, path string, order []digestSection) (digest string, section digestSection) { + for _, s := range order { if d, _, ok := control.ParsePathInfo(release.Get(s.name), path); ok { return d, s } @@ -358,7 +370,7 @@ func releaseDigest(release control.Section, path string) (digest string, section } func packageDigest(section control.Section) (digest string, kind cache.DigestKind) { - for _, s := range digestSections { + for _, s := range cacheDigestSections { if d := section.Get(s.name); d != "" { return d, s.kind } @@ -370,7 +382,7 @@ func packageDigest(section control.Section) (digest string, kind cache.DigestKin func (index *ubuntuIndex) fetchIndex() error { packagesPath := fmt.Sprintf("%s/binary-%s/Packages", index.component, index.arch) - packagesDigest, section := releaseDigest(index.release, packagesPath) + packagesDigest, section := findDigest(index.release, packagesPath, cacheDigestSections) if packagesDigest == "" { return fmt.Errorf("%s is missing from %s %s component digests", packagesPath, index.suite, index.component) } @@ -384,9 +396,14 @@ func (index *ubuntuIndex) fetchIndex() error { packagesGzPath := packagesPath + ".gz" var reader io.ReadSeekCloser if index.release.Get("Acquire-By-Hash") == "yes" { - packagesGzDigest, _, _ := control.ParsePathInfo(index.release.Get(section.name), packagesGzPath) + // By-hash directories are only guaranteed to exist for the strongest + // hash the archive advertises, so the URL is built from the strongest + // section, independently of the digest used for verification and the + // cache. If the archive advertises a hash stronger than any Chisel + // knows, the URL may 404 and the named-path fallback below applies. + packagesGzDigest, byHashSection := findDigest(index.release, packagesGzPath, digestSections) if packagesGzDigest != "" { - packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, section.name, packagesGzDigest) + packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, byHashSection.name, packagesGzDigest) r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, section.kind, fetchBulk|fetchGzip) if err != nil && err != errNotFound { return err diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 95cc448d6..a986b2488 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -293,7 +293,7 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { func (s *httpSuite) TestFetchBothDigests(c *C) { // An archive publishing both SHA256 and SHA512 sections (index table and - // package fields) must be handled, with SHA256 preferred per digestSections + // package fields) must be handled, with SHA256 preferred per the cache // ordering -- so PackageInfo.SHA256 is the field that surfaces. s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { @@ -322,6 +322,11 @@ func (s *httpSuite) TestFetchBothDigests(c *C) { SHA256: "1f08ef04cfe7a8087ee38a1ea35fa1810246648136c3c42d5a61ad6503d85e05", }) c.Assert(read(pkg), Equals, "mypkg1 1.1 data") + + // Pin the cache key: with both digests advertised, packages must stay + // cached under their SHA256 so existing caches keep their entries. + _, err = os.Stat(filepath.Join(options.CacheDir, "sha256", info.SHA256)) + c.Assert(err, IsNil) } func (s *httpSuite) TestFetchPortsPackage(c *C) { @@ -789,7 +794,8 @@ func (s *httpSuite) TestFetchByHashSHA512(c *C) { func (s *httpSuite) TestFetchByHashBothDigests(c *C) { // When a by-hash archive publishes both digests, the by-hash URL must be - // built under SHA256 (preferred) and SHA512 must not be requested. + // built under SHA512: archives only guarantee a by-hash directory for the + // strongest hash they advertise. SHA256 by-hash must not be requested. s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { release.ByHash = true release.DigestKinds = []string{"SHA256", "SHA512"} @@ -820,12 +826,12 @@ func (s *httpSuite) TestFetchByHashBothDigests(c *C) { c.Assert(err, IsNil) c.Assert(read(pkg), Equals, "mypkg1 1.1 data") - // The SHA256 by-hash request must have been made and succeeded; the SHA512 + // The SHA512 by-hash request must have been made and succeeded; the SHA256 // by-hash directory must never be touched. - attempted, status := s.fetchRequestStatus("/by-hash/SHA256/") + attempted, status := s.fetchRequestStatus("/by-hash/SHA512/") c.Assert(attempted, Equals, true) c.Assert(status, Equals, 200) - attempted, _ = s.fetchRequestStatus("/by-hash/SHA512/") + attempted, _ = s.fetchRequestStatus("/by-hash/SHA256/") c.Assert(attempted, Equals, false) } diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 11998f684..61a593369 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -7,6 +7,7 @@ import ( "crypto/sha512" "fmt" "path" + "slices" "strings" "golang.org/x/crypto/openpgp/clearsign" @@ -210,7 +211,10 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent if r.ByHash && itemPath != r.Path() { - for _, kind := range r.DigestKinds { + // Real archives (ftpmaster) only publish by-hash directories for + // the strongest hash they advertise; mirror that so tests catch + // clients building by-hash URLs from a weaker hash. + if kind := strongestKind(r.DigestKinds); kind != "" { byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", kind, makeDigest(kind, itemContent)) content[byHashPath] = itemContent } @@ -249,6 +253,17 @@ func (pi *PackageIndex) Content() []byte { return MergeSections(pi.Packages) } +// strongestKind returns the strongest digest kind in kinds, or "" if none is +// known. +func strongestKind(kinds []string) string { + for _, k := range []string{"SHA512", "SHA256"} { + if slices.Contains(kinds, k) { + return k + } + } + return "" +} + func makeDigest(kind string, b []byte) string { if kind == "SHA512" { return fmt.Sprintf("%x", sha512.Sum512(b)) From 744fae97fe61bf39bd0727a07e33b9f45cac7b13 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 10:08:49 +0100 Subject: [PATCH 16/30] refactor: name digest fields, not sections --- internal/archive/archive.go | 51 ++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 5dfa2a879..ab260ee6b 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -331,48 +331,47 @@ func (index *ubuntuIndex) fetchRelease() error { return nil } -// digestSection is an archive checksum section Chisel can verify. Its name +// digestField is an archive checksum field Chisel can verify. Its name // doubles as the by-hash directory name in the archive layout. -type digestSection struct { +type digestField struct { name string kind cache.DigestKind } -// digestSections lists the checksum sections Chisel can verify, strongest -// first. This order matches the by-hash archive layout: an archive is only -// guaranteed to publish a by-hash directory for the strongest hash it -// advertises. -var digestSections = []digestSection{ +// digestFields lists the checksum fields Chisel can verify, strongest first. +// This order matches the by-hash archive layout: an archive is only guaranteed +// to publish a by-hash directory for the strongest hash it advertises. +var digestFields = []digestField{ {"SHA512", cache.SHA512}, {"SHA256", cache.SHA256}, } -// cacheDigestSections is digestSections reordered to prefer SHA256, so that +// cacheDigestFields is digestFields reordered to prefer SHA256, so that // existing caches keep their keys. It picks the digest used to verify content // and key the cache, and nothing else. -// TODO Drop this in favour of digestSections. That changes cache keys for +// TODO Drop this in favour of digestFields. That changes cache keys for // archives publishing both digests, so it must wait for a minor release. -var cacheDigestSections = []digestSection{ +var cacheDigestFields = []digestField{ {"SHA256", cache.SHA256}, {"SHA512", cache.SHA512}, } // findDigest returns the checksum recorded for path in a Release " -// " table, along with the section it was found in, trying the -// sections in order. -func findDigest(release control.Section, path string, order []digestSection) (digest string, section digestSection) { - for _, s := range order { - if d, _, ok := control.ParsePathInfo(release.Get(s.name), path); ok { - return d, s +// " table, along with the field it was found in, trying the +// fields in order. +func findDigest(release control.Section, path string, order []digestField) (digest string, field digestField) { + for _, f := range order { + if d, _, ok := control.ParsePathInfo(release.Get(f.name), path); ok { + return d, f } } - return "", digestSection{} + return "", digestField{} } func packageDigest(section control.Section) (digest string, kind cache.DigestKind) { - for _, s := range cacheDigestSections { - if d := section.Get(s.name); d != "" { - return d, s.kind + for _, f := range cacheDigestFields { + if d := section.Get(f.name); d != "" { + return d, f.kind } } // No digest advertised; fall back to SHA256 so the package can still be @@ -382,7 +381,7 @@ func packageDigest(section control.Section) (digest string, kind cache.DigestKin func (index *ubuntuIndex) fetchIndex() error { packagesPath := fmt.Sprintf("%s/binary-%s/Packages", index.component, index.arch) - packagesDigest, section := findDigest(index.release, packagesPath, cacheDigestSections) + packagesDigest, cacheField := findDigest(index.release, packagesPath, cacheDigestFields) if packagesDigest == "" { return fmt.Errorf("%s is missing from %s %s component digests", packagesPath, index.suite, index.component) } @@ -398,13 +397,13 @@ func (index *ubuntuIndex) fetchIndex() error { if index.release.Get("Acquire-By-Hash") == "yes" { // By-hash directories are only guaranteed to exist for the strongest // hash the archive advertises, so the URL is built from the strongest - // section, independently of the digest used for verification and the + // field, independently of the digest used for verification and the // cache. If the archive advertises a hash stronger than any Chisel // knows, the URL may 404 and the named-path fallback below applies. - packagesGzDigest, byHashSection := findDigest(index.release, packagesGzPath, digestSections) + packagesGzDigest, byHashField := findDigest(index.release, packagesGzPath, digestFields) if packagesGzDigest != "" { - packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, byHashSection.name, packagesGzDigest) - r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, section.kind, fetchBulk|fetchGzip) + packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, byHashField.name, packagesGzDigest) + r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, cacheField.kind, fetchBulk|fetchGzip) if err != nil && err != errNotFound { return err } @@ -414,7 +413,7 @@ func (index *ubuntuIndex) fetchIndex() error { } } if reader == nil { - r, err := index.fetch(index.distPath(packagesGzPath), packagesDigest, section.kind, fetchBulk|fetchGzip) + r, err := index.fetch(index.distPath(packagesGzPath), packagesDigest, cacheField.kind, fetchBulk|fetchGzip) if err != nil { return err } From 8c5cbc1e9ecc59b2e19303da0282f569c34f8b50 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 10:09:54 +0100 Subject: [PATCH 17/30] fix: panic on unknown digest kind in test archive makeDigest silently fell back to SHA256 for any kind it did not know, so makeDigest("MD5", ...) returned a SHA256 sum. Panic instead, as the other helpers in this package do. --- internal/archive/testarchive/testarchive.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 61a593369..d41cca173 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -253,22 +253,28 @@ func (pi *PackageIndex) Content() []byte { return MergeSections(pi.Packages) } -// strongestKind returns the strongest digest kind in kinds, or "" if none is -// known. +// digestKinds lists the digest kinds this package can render, strongest first. +var digestKinds = []string{"SHA512", "SHA256"} + +// strongestKind returns the strongest of kinds, or "" if kinds holds none of +// digestKinds. func strongestKind(kinds []string) string { - for _, k := range []string{"SHA512", "SHA256"} { - if slices.Contains(kinds, k) { - return k + for _, kind := range digestKinds { + if slices.Contains(kinds, kind) { + return kind } } return "" } func makeDigest(kind string, b []byte) string { - if kind == "SHA512" { + switch kind { + case "SHA512": return fmt.Sprintf("%x", sha512.Sum512(b)) + case "SHA256": + return fmt.Sprintf("%x", sha256.Sum256(b)) } - return fmt.Sprintf("%x", sha256.Sum256(b)) + panic("unknown digest kind: " + kind) } func MakeGzip(b []byte) []byte { From 7a308a375bc6cfc07a127533289de0646e15f774 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 10:13:03 +0100 Subject: [PATCH 18/30] refactor: drop redundant inheritDigestKinds call Render calls it before walking the items, and Walk visits the Release first, so Content always runs after the packages have inherited. --- internal/archive/testarchive/testarchive.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index d41cca173..89a755ea1 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -142,8 +142,9 @@ func (r *Release) Section() []byte { } // inheritDigestKinds copies the release's digest kinds onto every package it -// publishes, so the archive-wide choice reaches each package section. Run at -// render time and idempotent, so it always sees the final release state. +// publishes, so the archive-wide choice reaches each package section. Render +// calls it before walking the items, so it always sees the final release +// state. func (r *Release) inheritDigestKinds() { for _, item := range r.Items { // The callback never errors, so neither can Walk. @@ -157,7 +158,6 @@ func (r *Release) inheritDigestKinds() { } func (r *Release) Content() []byte { - r.inheritDigestKinds() digests := bytes.Buffer{} for _, kind := range r.DigestKinds { fmt.Fprintf(&digests, "%s:\n", kind) From 2ec8ab9e8948df27c91d8f2236b7c7e37821b3c2 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 10:25:53 +0100 Subject: [PATCH 19/30] refactor: propagate Walk errors in test archive inheritDigestKinds assumed its callback could never error, so neither could Walk. Return the error instead, and handle it at the call sites, so a future Walk with a real error path cannot fail silently. --- internal/archive/archive_test.go | 11 ++++++++--- internal/archive/testarchive/testarchive.go | 14 ++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index a986b2488..1bcce618b 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -159,7 +159,10 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com if adjustRelease != nil { adjustRelease(release) } - release.Render(base.Path, s.responses) + err = release.Render(base.Path, s.responses) + if err != nil { + panic(err) + } return release } @@ -375,14 +378,16 @@ func (s *httpSuite) TestFetchSecurityPackage(c *C) { for i, suite := range []string{"jammy", "jammy-updates", "jammy-security"} { release := s.prepareArchive(suite, "22.04", "amd64", []string{"main", "universe"}) - release.Walk(func(item testarchive.Item) error { + err := release.Walk(func(item testarchive.Item) error { if p, ok := item.(*testarchive.Package); ok && p.Name == "mypkg1" { p.Version = fmt.Sprintf("%s.%d", p.Version, i) p.Data = []byte("package from " + suite) } return nil }) - release.Render("/ubuntu", s.responses) + c.Assert(err, IsNil) + err = release.Render("/ubuntu", s.responses) + c.Assert(err, IsNil) } options := archive.Options{ diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 89a755ea1..095db0f83 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -145,16 +145,19 @@ func (r *Release) Section() []byte { // publishes, so the archive-wide choice reaches each package section. Render // calls it before walking the items, so it always sees the final release // state. -func (r *Release) inheritDigestKinds() { +func (r *Release) inheritDigestKinds() error { for _, item := range r.Items { - // The callback never errors, so neither can Walk. - _ = item.Walk(func(item Item) error { + err := item.Walk(func(item Item) error { if p, ok := item.(*Package); ok { p.digestKinds = r.DigestKinds } return nil }) + if err != nil { + return err + } } + return nil } func (r *Release) Content() []byte { @@ -200,7 +203,10 @@ func (r *Release) Content() []byte { } func (r *Release) Render(prefix string, content map[string][]byte) error { - r.inheritDigestKinds() + err := r.inheritDigestKinds() + if err != nil { + return err + } return r.Walk(func(item Item) error { itemPath := item.Path() itemContent := item.Content() From c5ce9e3ff17a92697016be33130cd4378fd08a39 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 10:26:38 +0100 Subject: [PATCH 20/30] docs: describe digestKinds field, not its writer --- internal/archive/testarchive/testarchive.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 095db0f83..66a0ee074 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -65,9 +65,9 @@ type Package struct { Arch string Component string Data []byte - // digestKinds is filled from the release at render time (see - // inheritDigestKinds). Digest kinds are an archive-wide choice, so tests set - // them on the Release, not per package. + // digestKinds names the digest kinds published in this package's section. + // It is copied from Release.DigestKinds at render time, digest kinds being + // an archive-wide choice rather than a per-package one. digestKinds []string } From 13977093a0a336bd04f93f45ad34d4a3e8e42c27 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 11:19:54 +0100 Subject: [PATCH 21/30] refactor: packages read digest kinds through the release Replace the render-time copy of the release's digest kinds with a backpointer set when the release adopts the package. Sections always see the release's current choice, and rendering a section before any release adopted the package panics instead of silently rendering without digests. --- internal/archive/testarchive/testarchive.go | 26 +++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 66a0ee074..2e9f706e2 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -65,10 +65,10 @@ type Package struct { Arch string Component string Data []byte - // digestKinds names the digest kinds published in this package's section. - // It is copied from Release.DigestKinds at render time, digest kinds being - // an archive-wide choice rather than a per-package one. - digestKinds []string + // release is set when a release adopts this package at render time. + // Section reads the archive-wide digest kinds through it, so the package + // always sees the release's current choice. + release *Release } func (p *Package) Path() string { @@ -80,9 +80,12 @@ func (p *Package) Walk(f func(Item) error) error { } func (p *Package) Section() []byte { + if p.release == nil { + panic("package section rendered before a release adopted the package; render via Release.Render") + } content := p.Content() digests := strings.Builder{} - for i, kind := range p.digestKinds { + for i, kind := range p.release.DigestKinds { if i > 0 { digests.WriteByte('\n') } @@ -141,15 +144,14 @@ func (r *Release) Section() []byte { return nil } -// inheritDigestKinds copies the release's digest kinds onto every package it -// publishes, so the archive-wide choice reaches each package section. Render -// calls it before walking the items, so it always sees the final release -// state. -func (r *Release) inheritDigestKinds() error { +// adoptPackages points every published package back at this release, giving +// the package sections access to the archive-wide digest kinds. Render calls +// it before walking the items. +func (r *Release) adoptPackages() error { for _, item := range r.Items { err := item.Walk(func(item Item) error { if p, ok := item.(*Package); ok { - p.digestKinds = r.DigestKinds + p.release = r } return nil }) @@ -203,7 +205,7 @@ func (r *Release) Content() []byte { } func (r *Release) Render(prefix string, content map[string][]byte) error { - err := r.inheritDigestKinds() + err := r.adoptPackages() if err != nil { return err } From 7cf965bf7327ebfa0a2dd3a9aef0f800bd9fed93 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 11:46:51 +0100 Subject: [PATCH 22/30] refactor: adopt packages through Walk adoptPackages hand-rolled the recursion and error propagation that Walk already provides. --- internal/archive/testarchive/testarchive.go | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 2e9f706e2..395b27f5a 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -148,18 +148,12 @@ func (r *Release) Section() []byte { // the package sections access to the archive-wide digest kinds. Render calls // it before walking the items. func (r *Release) adoptPackages() error { - for _, item := range r.Items { - err := item.Walk(func(item Item) error { - if p, ok := item.(*Package); ok { - p.release = r - } - return nil - }) - if err != nil { - return err + return r.Walk(func(item Item) error { + if p, ok := item.(*Package); ok { + p.release = r } - } - return nil + return nil + }) } func (r *Release) Content() []byte { From abfc91fdf60da4e5d30fce87210142f594e91c4d Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 22:50:06 +0100 Subject: [PATCH 23/30] refactor: rename adoptPackages to wirePackages "adopt" implied release-owns-package, but the wiring runs the other way: each package gets a back-reference to the release. --- internal/archive/testarchive/testarchive.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 395b27f5a..3ba919048 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -144,10 +144,10 @@ func (r *Release) Section() []byte { return nil } -// adoptPackages points every published package back at this release, giving +// wirePackages points every published package back at this release, giving // the package sections access to the archive-wide digest kinds. Render calls // it before walking the items. -func (r *Release) adoptPackages() error { +func (r *Release) wirePackages() error { return r.Walk(func(item Item) error { if p, ok := item.(*Package); ok { p.release = r @@ -199,7 +199,7 @@ func (r *Release) Content() []byte { } func (r *Release) Render(prefix string, content map[string][]byte) error { - err := r.adoptPackages() + err := r.wirePackages() if err != nil { return err } From 0ebadf2d46ba40f347880ba53ba8a755853de086 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 22:51:28 +0100 Subject: [PATCH 24/30] docs: keep findDigest comment above the table format --- internal/archive/archive.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index ab260ee6b..01393fbfc 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -356,9 +356,8 @@ var cacheDigestFields = []digestField{ {"SHA512", cache.SHA512}, } -// findDigest returns the checksum recorded for path in a Release " -// " table, along with the field it was found in, trying the -// fields in order. +// findDigest returns the digest recorded for path in the release, along with +// the field it was found in, trying the given fields in order. func findDigest(release control.Section, path string, order []digestField) (digest string, field digestField) { for _, f := range order { if d, _, ok := control.ParsePathInfo(release.Get(f.name), path); ok { From 606e7a174b082b1fa9f3c4e1a800934c00afb05e Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 23:23:20 +0100 Subject: [PATCH 25/30] test: cover by-hash dirs beyond the strongest digest --- internal/archive/archive_test.go | 103 +++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 1bcce618b..54301cd6e 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -4,6 +4,7 @@ import ( "golang.org/x/crypto/openpgp/packet" . "gopkg.in/check.v1" + "crypto/sha256" "debug/elf" "errors" "flag" @@ -840,6 +841,108 @@ func (s *httpSuite) TestFetchByHashBothDigests(c *C) { c.Assert(attempted, Equals, false) } +// addSha256ByHashDirs mirrors every published by-hash/SHA512 entry under a +// by-hash/SHA256 path, like archives publishing by-hash directories for +// more hashes than just the strongest one (e.g. stonking). +func (s *httpSuite) addSha256ByHashDirs() { + mirrored := make(map[string][]byte) + for p, content := range s.responses { + prefix, _, found := strings.Cut(p, "/by-hash/SHA512/") + if !found { + continue + } + mirrored[fmt.Sprintf("%s/by-hash/SHA256/%x", prefix, sha256.Sum256(content))] = content + } + for p, content := range mirrored { + s.responses[p] = content + } +} + +func (s *httpSuite) TestFetchByHashManyDigestDirs(c *C) { + // An archive may publish by-hash directories for more hashes than the + // strongest one it advertises. The by-hash URL must still be built under + // SHA512, and the SHA256 directory left alone, even though a request + // there would now succeed. + s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { + release.ByHash = true + release.DigestKinds = []string{"SHA256", "SHA512"} + }) + s.addSha256ByHashDirs() + + // Stale content at the named Packages.gz path, so a fallback would fail + // the digest check -- only the by-hash paths serve the correct bytes. + for p := range s.responses { + if strings.Contains(p, "Packages.gz") && !strings.Contains(p, "/by-hash/") { + s.responses[p] = testarchive.MakeGzip([]byte("stale Packages from previous publication")) + } + } + + options := archive.Options{ + Label: "ubuntu", + Version: "26.10", + Arch: "amd64", + Suites: []string{"stonking"}, + Components: []string{"main"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + pkg, _, err := testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + c.Assert(read(pkg), Equals, "mypkg1 1.1 data") + + attempted, status := s.fetchRequestStatus("/by-hash/SHA512/") + c.Assert(attempted, Equals, true) + c.Assert(status, Equals, 200) + attempted, _ = s.fetchRequestStatus("/by-hash/SHA256/") + c.Assert(attempted, Equals, false) +} + +func (s *httpSuite) TestFetchByHashOnlyWeakerDigestDir(c *C) { + // A buggy archive advertising both digests but publishing a by-hash + // directory only for the weaker one. The SHA512 by-hash request 404s and + // the named path takes over; the SHA256 directory must not be tried. + s.prepareArchiveAdjustRelease("stonking", "26.10", "amd64", []string{"main"}, func(release *testarchive.Release) { + release.ByHash = true + release.DigestKinds = []string{"SHA256", "SHA512"} + }) + s.addSha256ByHashDirs() + for p := range s.responses { + if strings.Contains(p, "/by-hash/SHA512/") { + delete(s.responses, p) + } + } + + options := archive.Options{ + Label: "ubuntu", + Version: "26.10", + Arch: "amd64", + Suites: []string{"stonking"}, + Components: []string{"main"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + pkg, _, err := testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + c.Assert(read(pkg), Equals, "mypkg1 1.1 data") + + attempted, status := s.fetchRequestStatus("/by-hash/SHA512/") + c.Assert(attempted, Equals, true) + c.Assert(status, Equals, 404) + attempted, _ = s.fetchRequestStatus("/by-hash/SHA256/") + c.Assert(attempted, Equals, false) + attempted, status = s.fetchRequestStatus("Packages.gz") + c.Assert(attempted, Equals, true) + c.Assert(status, Equals, 200) +} + func (s *httpSuite) TestFetchByHashFallsBackOnNotFound(c *C) { s.prepareArchiveAdjustRelease("jammy", "22.04", "amd64", []string{"main"}, func(r *testarchive.Release) { r.ByHash = true From 86024cdfc2217041001d9565f7685d0907e11882 Mon Sep 17 00:00:00 2001 From: lczyk Date: Wed, 8 Jul 2026 23:25:08 +0100 Subject: [PATCH 26/30] docs: by-hash dirs beyond the strongest are legal --- internal/archive/testarchive/testarchive.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 3ba919048..4519aea5d 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -213,9 +213,11 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent if r.ByHash && itemPath != r.Path() { - // Real archives (ftpmaster) only publish by-hash directories for - // the strongest hash they advertise; mirror that so tests catch - // clients building by-hash URLs from a weaker hash. + // Archives only guarantee a by-hash directory for the strongest + // hash they advertise, though they may publish more. Render the + // guaranteed one only, so tests catch clients building by-hash + // URLs from a weaker hash; tests wanting extra directories add + // them on top of the rendered content. if kind := strongestKind(r.DigestKinds); kind != "" { byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", kind, makeDigest(kind, itemContent)) content[byHashPath] = itemContent From b70e1b43a397d587242a8f3a2008c02bf0fc6580 Mon Sep 17 00:00:00 2001 From: Marcin Konowalczyk Date: Thu, 9 Jul 2026 11:01:38 +0100 Subject: [PATCH 27/30] Update internal/archive/testarchive/testarchive.go Co-authored-by: Upils <5464641+upils@users.noreply.github.com> --- internal/archive/testarchive/testarchive.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 4519aea5d..c99c5e64e 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -144,9 +144,7 @@ func (r *Release) Section() []byte { return nil } -// wirePackages points every published package back at this release, giving -// the package sections access to the archive-wide digest kinds. Render calls -// it before walking the items. +// wirePackages gives the package sections access to the archive-wide digest kinds. func (r *Release) wirePackages() error { return r.Walk(func(item Item) error { if p, ok := item.(*Package); ok { From 311052ee500e1ec8929688cdfcd6cc5fd4749e4d Mon Sep 17 00:00:00 2001 From: lczyk Date: Thu, 9 Jul 2026 11:11:06 +0100 Subject: [PATCH 28/30] refactor: single strongest-first digest preference Drop the SHA256-first cache ordering: content is verified and cached under the strongest advertised digest. Worst case, entries cached by an older chisel under SHA256 go cold and are fetched once more. No current archive advertises both digests, so today this changes nothing. --- internal/archive/archive.go | 32 +++++++++++--------------------- internal/archive/archive_test.go | 13 ++++++++----- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 01393fbfc..1d3f1af69 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -338,24 +338,15 @@ type digestField struct { kind cache.DigestKind } -// digestFields lists the checksum fields Chisel can verify, strongest first. -// This order matches the by-hash archive layout: an archive is only guaranteed -// to publish a by-hash directory for the strongest hash it advertises. +// digestFields lists the checksum fields Chisel can verify, in order of +// preference: strongest first. The order also matches the by-hash archive +// layout, where only the by-hash directory of the strongest advertised hash +// is guaranteed to exist. var digestFields = []digestField{ {"SHA512", cache.SHA512}, {"SHA256", cache.SHA256}, } -// cacheDigestFields is digestFields reordered to prefer SHA256, so that -// existing caches keep their keys. It picks the digest used to verify content -// and key the cache, and nothing else. -// TODO Drop this in favour of digestFields. That changes cache keys for -// archives publishing both digests, so it must wait for a minor release. -var cacheDigestFields = []digestField{ - {"SHA256", cache.SHA256}, - {"SHA512", cache.SHA512}, -} - // findDigest returns the digest recorded for path in the release, along with // the field it was found in, trying the given fields in order. func findDigest(release control.Section, path string, order []digestField) (digest string, field digestField) { @@ -368,7 +359,7 @@ func findDigest(release control.Section, path string, order []digestField) (dige } func packageDigest(section control.Section) (digest string, kind cache.DigestKind) { - for _, f := range cacheDigestFields { + for _, f := range digestFields { if d := section.Get(f.name); d != "" { return d, f.kind } @@ -380,7 +371,7 @@ func packageDigest(section control.Section) (digest string, kind cache.DigestKin func (index *ubuntuIndex) fetchIndex() error { packagesPath := fmt.Sprintf("%s/binary-%s/Packages", index.component, index.arch) - packagesDigest, cacheField := findDigest(index.release, packagesPath, cacheDigestFields) + packagesDigest, field := findDigest(index.release, packagesPath, digestFields) if packagesDigest == "" { return fmt.Errorf("%s is missing from %s %s component digests", packagesPath, index.suite, index.component) } @@ -395,14 +386,13 @@ func (index *ubuntuIndex) fetchIndex() error { var reader io.ReadSeekCloser if index.release.Get("Acquire-By-Hash") == "yes" { // By-hash directories are only guaranteed to exist for the strongest - // hash the archive advertises, so the URL is built from the strongest - // field, independently of the digest used for verification and the - // cache. If the archive advertises a hash stronger than any Chisel - // knows, the URL may 404 and the named-path fallback below applies. + // hash the archive advertises, which is what findDigest prefers. If + // the archive advertises a hash stronger than any Chisel knows, the + // URL may 404 and the named-path fallback below applies. packagesGzDigest, byHashField := findDigest(index.release, packagesGzPath, digestFields) if packagesGzDigest != "" { packagesByHashPath := fmt.Sprintf("%s/binary-%s/by-hash/%s/%s", index.component, index.arch, byHashField.name, packagesGzDigest) - r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, cacheField.kind, fetchBulk|fetchGzip) + r, err := index.fetch(index.distPath(packagesByHashPath), packagesDigest, field.kind, fetchBulk|fetchGzip) if err != nil && err != errNotFound { return err } @@ -412,7 +402,7 @@ func (index *ubuntuIndex) fetchIndex() error { } } if reader == nil { - r, err := index.fetch(index.distPath(packagesGzPath), packagesDigest, cacheField.kind, fetchBulk|fetchGzip) + r, err := index.fetch(index.distPath(packagesGzPath), packagesDigest, field.kind, fetchBulk|fetchGzip) if err != nil { return err } diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 54301cd6e..9f19028cd 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -5,6 +5,7 @@ import ( . "gopkg.in/check.v1" "crypto/sha256" + "crypto/sha512" "debug/elf" "errors" "flag" @@ -297,8 +298,9 @@ func (s *httpSuite) TestFetchSHA512Digests(c *C) { func (s *httpSuite) TestFetchBothDigests(c *C) { // An archive publishing both SHA256 and SHA512 sections (index table and - // package fields) must be handled, with SHA256 preferred per the cache - // ordering -- so PackageInfo.SHA256 is the field that surfaces. + // package fields) must be handled, with the strongest digest preferred + // for verification and caching. PackageInfo.SHA256 still surfaces: it is + // read from the package section directly, not from the preference order. s.prepareArchiveAdjustRelease("stonking", "25.10", "amd64", []string{"main", "universe"}, func(release *testarchive.Release) { release.DigestKinds = []string{"SHA256", "SHA512"} @@ -327,9 +329,10 @@ func (s *httpSuite) TestFetchBothDigests(c *C) { }) c.Assert(read(pkg), Equals, "mypkg1 1.1 data") - // Pin the cache key: with both digests advertised, packages must stay - // cached under their SHA256 so existing caches keep their entries. - _, err = os.Stat(filepath.Join(options.CacheDir, "sha256", info.SHA256)) + // Pin the cache key: with both digests advertised, the package is cached + // under its strongest digest. + sha512Digest := fmt.Sprintf("%x", sha512.Sum512([]byte("mypkg1 1.1 data"))) + _, err = os.Stat(filepath.Join(options.CacheDir, "sha512", sha512Digest)) c.Assert(err, IsNil) } From 0c9e9e3b3368ac8c5a1d4e3926edc0856be69eeb Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Wed, 15 Jul 2026 10:57:32 +0200 Subject: [PATCH 29/30] test: pass DigestKinds instead of release pointer Signed-off-by: Paul Mars --- internal/archive/archive_test.go | 15 ++++----- internal/archive/testarchive/testarchive.go | 34 +++++---------------- 2 files changed, 15 insertions(+), 34 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 9f19028cd..a95a5065b 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -137,6 +137,9 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com PrivKey: s.privKey, DigestKinds: []string{"SHA256"}, } + if adjustRelease != nil { + adjustRelease(release) + } for i, component := range components { index := &testarchive.PackageIndex{ Component: component, @@ -145,10 +148,11 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com for j := range 2 { seq := 1 + i*2 + j index.Packages = append(index.Packages, &testarchive.Package{ - Name: fmt.Sprintf("mypkg%d", seq), - Version: fmt.Sprintf("1.%d", seq), - Arch: arch, - Component: component, + Name: fmt.Sprintf("mypkg%d", seq), + Version: fmt.Sprintf("1.%d", seq), + Arch: arch, + Component: component, + DigestKinds: release.DigestKinds, }) } release.Items = append(release.Items, index) @@ -158,9 +162,6 @@ func (s *httpSuite) prepareArchiveAdjustRelease(suite, version, arch string, com if err != nil { panic(err) } - if adjustRelease != nil { - adjustRelease(release) - } err = release.Render(base.Path, s.responses) if err != nil { panic(err) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index c99c5e64e..242e1dac8 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -60,15 +60,12 @@ func (gz *Gzip) Content() []byte { } type Package struct { - Name string - Version string - Arch string - Component string - Data []byte - // release is set when a release adopts this package at render time. - // Section reads the archive-wide digest kinds through it, so the package - // always sees the release's current choice. - release *Release + Name string + Version string + Arch string + Component string + Data []byte + DigestKinds []string } func (p *Package) Path() string { @@ -80,12 +77,9 @@ func (p *Package) Walk(f func(Item) error) error { } func (p *Package) Section() []byte { - if p.release == nil { - panic("package section rendered before a release adopted the package; render via Release.Render") - } content := p.Content() digests := strings.Builder{} - for i, kind := range p.release.DigestKinds { + for i, kind := range p.DigestKinds { if i > 0 { digests.WriteByte('\n') } @@ -144,16 +138,6 @@ func (r *Release) Section() []byte { return nil } -// wirePackages gives the package sections access to the archive-wide digest kinds. -func (r *Release) wirePackages() error { - return r.Walk(func(item Item) error { - if p, ok := item.(*Package); ok { - p.release = r - } - return nil - }) -} - func (r *Release) Content() []byte { digests := bytes.Buffer{} for _, kind := range r.DigestKinds { @@ -197,10 +181,6 @@ func (r *Release) Content() []byte { } func (r *Release) Render(prefix string, content map[string][]byte) error { - err := r.wirePackages() - if err != nil { - return err - } return r.Walk(func(item Item) error { itemPath := item.Path() itemContent := item.Content() From b9da3c91d435ceb1f4363fbeb8a2be0006ed50b2 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Wed, 15 Jul 2026 11:41:17 +0200 Subject: [PATCH 30/30] refactor: strongest kind at the release level Signed-off-by: Paul Mars --- internal/archive/testarchive/testarchive.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/archive/testarchive/testarchive.go b/internal/archive/testarchive/testarchive.go index 242e1dac8..c4ef7822b 100644 --- a/internal/archive/testarchive/testarchive.go +++ b/internal/archive/testarchive/testarchive.go @@ -181,6 +181,7 @@ func (r *Release) Content() []byte { } func (r *Release) Render(prefix string, content map[string][]byte) error { + byHashKind := strongestKind(r.DigestKinds) return r.Walk(func(item Item) error { itemPath := item.Path() itemContent := item.Content() @@ -190,16 +191,14 @@ func (r *Release) Render(prefix string, content map[string][]byte) error { } distItemPath := path.Join(prefix, "dists", r.Suite, itemPath) content[distItemPath] = itemContent - if r.ByHash && itemPath != r.Path() { - // Archives only guarantee a by-hash directory for the strongest - // hash they advertise, though they may publish more. Render the - // guaranteed one only, so tests catch clients building by-hash - // URLs from a weaker hash; tests wanting extra directories add - // them on top of the rendered content. - if kind := strongestKind(r.DigestKinds); kind != "" { - byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", kind, makeDigest(kind, itemContent)) - content[byHashPath] = itemContent - } + // Archives only guarantee a by-hash directory for the strongest + // hash they advertise, though they may publish more. Render the + // guaranteed one only, so tests catch clients building by-hash + // URLs from a weaker hash; tests wanting extra directories add + // them on top of the rendered content. + if r.ByHash && itemPath != r.Path() && byHashKind != "" { + byHashPath := path.Join(prefix, "dists", r.Suite, path.Dir(itemPath), "by-hash", byHashKind, makeDigest(byHashKind, itemContent)) + content[byHashPath] = itemContent } return nil })