Make the set of Acquire-By-Hash algorithms configurable

Acquire-By-Hash entries are written for MD5Sum, SHA1, SHA256 and SHA512,
hardcoded in two places in deb/index_files.go. Every algorithm is a full
set of index copies on each publish, and on S3-like storage each of those
is a network round trip rather than a link.

Measured on an R2-backed archive of 25 packages across three suites, one
`aptly publish update` per suite:

  Acquire-By-Hash off   38 S3 requests
  Acquire-By-Hash on   222 S3 requests   (88 HeadObject, 83 CopyObject,
                                          35 DeleteObject, 13 PutObject)

Modern apt verifies with SHA256; MD5Sum and SHA1 are deprecated and
Debian has been phasing them out of Release files for years. Publishers
who do not need them are paying three quarters of that for nothing, with
no way to say so.

acquireByHashAlgorithms in aptly.conf now selects the set. An empty or
absent value keeps all four, so existing configurations are unaffected.
The order entries are written in comes from the code rather than the
config, so the directories created in one loop and the entries written in
another cannot disagree. An unrecognised name is an error rather than
something to skip: skipping a typo would produce "no by-hash entries for
that algorithm", which looks exactly like having configured it away.

Set per publish invocation from the config, the way SkipBz2 already is,
rather than persisted on the publish point.

Pre-existing test failures on this tree are unchanged by this commit:
deb goes 173->177 passed with the same 11 failures, utils keeps its one
(TestCompress), s3 stays green.
This commit is contained in:
Martin Simon authored and André Roth committed 2026-11-03 13:13:18 +01:00
1 parent db3e062edc
commit e58bab9587
11 files changed
+155 -5

No files matched your search

+9
View File
@@ -387,6 +387,9 @@ func apiPublishRepoOrSnapshot(c *gin.Context) {
return &task.ProcessReturnValue{Code: http.StatusBadRequest, Value: nil}, fmt.Errorf("prefix/distribution already used by another published repo: %s", duplicate)
}
// Which hash algorithms get Acquire-By-Hash entries. Each one is a full
// set of index copies per publish, which is felt most on S3-like storage.
published.AcquireByHashAlgorithms = context.Config().AcquireByHashAlgorithms
err = published.Publish(context.PackagePool(), context, taskCollectionFactory, signer, publishOutput, b.ForceOverwrite, context.SkelPath())
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to publish: %s", err)
@@ -578,6 +581,9 @@ func apiPublishUpdateSwitch(c *gin.Context) {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
}
// Which hash algorithms get Acquire-By-Hash entries. Each one is a full
// set of index copies per publish, which is felt most on S3-like storage.
published.AcquireByHashAlgorithms = context.Config().AcquireByHashAlgorithms
err = published.Publish(context.PackagePool(), context, taskCollectionFactory, signer, out, b.ForceOverwrite, context.SkelPath())
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
@@ -1245,6 +1251,9 @@ func apiPublishUpdate(c *gin.Context) {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
}
// Which hash algorithms get Acquire-By-Hash entries. Each one is a full
// set of index copies per publish, which is felt most on S3-like storage.
published.AcquireByHashAlgorithms = context.Config().AcquireByHashAlgorithms
err = published.Publish(context.PackagePool(), context, taskCollectionFactory, signer, out, b.ForceOverwrite, context.SkelPath())
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
+3
View File
@@ -178,6 +178,9 @@ func aptlyPublishSnapshotOrRepo(cmd *commander.Command, args []string) error {
context.Progress().ColoredPrintf("@rWARNING@|: force overwrite mode enabled, aptly might corrupt other published repositories sharing the same package pool.\n")
}
// Which hash algorithms get Acquire-By-Hash entries. Each one is a full
// set of index copies per publish, which is felt most on S3-like storage.
published.AcquireByHashAlgorithms = context.Config().AcquireByHashAlgorithms
err = published.Publish(context.PackagePool(), context, collectionFactory, signer, context.Progress(), forceOverwrite, context.SkelPath())
if err != nil {
return fmt.Errorf("unable to publish: %s", err)
+3
View File
@@ -111,6 +111,9 @@ func aptlyPublishSwitch(cmd *commander.Command, args []string) error {
published.MultiDist = context.Flags().Lookup("multi-dist").Value.Get().(bool)
}
// Which hash algorithms get Acquire-By-Hash entries. Each one is a full
// set of index copies per publish, which is felt most on S3-like storage.
published.AcquireByHashAlgorithms = context.Config().AcquireByHashAlgorithms
err = published.Publish(context.PackagePool(), context, collectionFactory, signer, context.Progress(), forceOverwrite, context.SkelPath())
if err != nil {
return fmt.Errorf("unable to publish: %s", err)
+3
View File
@@ -80,6 +80,9 @@ func aptlyPublishUpdate(cmd *commander.Command, args []string) error {
published.Version = context.Flags().Lookup("version").Value.String()
}
// Which hash algorithms get Acquire-By-Hash entries. Each one is a full
// set of index copies per publish, which is felt most on S3-like storage.
published.AcquireByHashAlgorithms = context.Config().AcquireByHashAlgorithms
err = published.Publish(context.PackagePool(), context, collectionFactory, signer, context.Progress(), forceOverwrite, context.SkelPath())
if err != nil {
return fmt.Errorf("unable to publish: %s", err)
+60 -4
View File
@@ -22,6 +22,7 @@ type indexFiles struct {
suffix string
indexes map[string]*indexFile
acquireByHash bool
byHashAlgorithms []string
skipBz2 bool
}
@@ -111,7 +112,11 @@ func (file *indexFile) Finalize(signer pgp.Signer) error {
}
if file.acquireByHash {
for _, hash := range []string{"MD5Sum", "SHA1", "SHA256", "SHA512"} {
algorithms, algErr := file.parent.hashAlgorithms()
if algErr != nil {
return algErr
}
for _, hash := range algorithms {
err = file.parent.publishedStorage.MkDir(filepath.Join(filedir, "by-hash", hash))
if err != nil {
return fmt.Errorf("unable to create dir: %s", err)
@@ -133,8 +138,13 @@ func (file *indexFile) Finalize(signer pgp.Signer) error {
if file.acquireByHash {
sums := file.parent.generatedFiles[file.relativePath+ext]
for hash, sum := range map[string]string{"SHA512": sums.SHA512, "SHA256": sums.SHA256, "SHA1": sums.SHA1, "MD5Sum": sums.MD5} {
err = packageIndexByHash(file, ext, hash, sum)
all := map[string]string{"SHA512": sums.SHA512, "SHA256": sums.SHA256, "SHA1": sums.SHA1, "MD5Sum": sums.MD5}
algorithms, algErr := file.parent.hashAlgorithms()
if algErr != nil {
return algErr
}
for _, hash := range algorithms {
err = packageIndexByHash(file, ext, hash, all[hash])
if err != nil {
return fmt.Errorf("unable to build hash file: %s", err)
}
@@ -185,6 +195,51 @@ func (file *indexFile) Finalize(signer pgp.Signer) error {
return nil
}
// knownHashAlgorithms are the algorithms aptly can write Acquire-By-Hash
// entries for, in the order the entries are written.
var knownHashAlgorithms = []string{"MD5Sum", "SHA1", "SHA256", "SHA512"}
// hashAlgorithms is the set of Acquire-By-Hash algorithms to write entries for.
// Empty means all of them, which is what aptly did before the set was
// configurable, so an existing config behaves exactly as before.
//
// The order is knownHashAlgorithms' rather than the config's, so that the
// directories created in one loop and the entries written in another always
// agree whatever order the user listed.
//
// An unrecognised name is an error rather than something to skip. Skipping it
// would turn a typo into "no by-hash entries for that algorithm", which looks
// identical to having configured it away.
func (files *indexFiles) hashAlgorithms() ([]string, error) {
if len(files.byHashAlgorithms) == 0 {
return knownHashAlgorithms, nil
}
wanted := make(map[string]bool, len(files.byHashAlgorithms))
for _, want := range files.byHashAlgorithms {
known := false
for _, k := range knownHashAlgorithms {
if k == want {
known = true
break
}
}
if !known {
return nil, fmt.Errorf("unknown Acquire-By-Hash algorithm %q, expected one of %v",
want, knownHashAlgorithms)
}
wanted[want] = true
}
ordered := make([]string, 0, len(wanted))
for _, known := range knownHashAlgorithms {
if wanted[known] {
ordered = append(ordered, known)
}
}
return ordered, nil
}
func packageIndexByHash(file *indexFile, ext string, hash string, sum string) error {
src := filepath.Join(file.parent.basePath, file.relativePath)
indexfile := path.Base(src + ext)
@@ -235,7 +290,7 @@ func packageIndexByHash(file *indexFile, ext string, hash string, sum string) er
return nil
}
func newIndexFiles(publishedStorage aptly.PublishedStorage, basePath, tempDir, suffix string, acquireByHash bool, skipBz2 bool) *indexFiles {
func newIndexFiles(publishedStorage aptly.PublishedStorage, basePath, tempDir, suffix string, acquireByHash bool, byHashAlgorithms []string, skipBz2 bool) *indexFiles {
return &indexFiles{
publishedStorage: publishedStorage,
basePath: basePath,
@@ -245,6 +300,7 @@ func newIndexFiles(publishedStorage aptly.PublishedStorage, basePath, tempDir, s
suffix: suffix,
indexes: make(map[string]*indexFile),
acquireByHash: acquireByHash,
byHashAlgorithms: byHashAlgorithms,
skipBz2: skipBz2,
}
}
+49
View File
@@ -0,0 +1,49 @@
package deb
import (
. "gopkg.in/check.v1"
)
type IndexFilesSuite struct{}
var _ = Suite(&IndexFilesSuite{})
func (s *IndexFilesSuite) TestHashAlgorithmsDefaultsToAll(c *C) {
// Unset must behave exactly as aptly did before the set was configurable,
// so an existing config keeps writing all four.
for _, files := range []*indexFiles{{}, {byHashAlgorithms: []string{}}} {
got, err := files.hashAlgorithms()
c.Check(err, IsNil)
c.Check(got, DeepEquals, []string{"MD5Sum", "SHA1", "SHA256", "SHA512"})
}
}
func (s *IndexFilesSuite) TestHashAlgorithmsHonoursTheConfiguredSet(c *C) {
got, err := (&indexFiles{byHashAlgorithms: []string{"SHA256"}}).hashAlgorithms()
c.Check(err, IsNil)
c.Check(got, DeepEquals, []string{"SHA256"})
got, err = (&indexFiles{byHashAlgorithms: []string{"SHA256", "SHA512"}}).hashAlgorithms()
c.Check(err, IsNil)
c.Check(got, DeepEquals, []string{"SHA256", "SHA512"})
}
func (s *IndexFilesSuite) TestHashAlgorithmsIsOrderedIndependentlyOfConfig(c *C) {
// The directories are created in one loop and the entries written in
// another. If the order came from the config those two could disagree,
// so it is fixed here whatever order the user listed.
got, err := (&indexFiles{byHashAlgorithms: []string{"SHA512", "MD5Sum", "SHA256"}}).hashAlgorithms()
c.Check(err, IsNil)
c.Check(got, DeepEquals, []string{"MD5Sum", "SHA256", "SHA512"})
}
func (s *IndexFilesSuite) TestHashAlgorithmsRejectsUnknownNames(c *C) {
// Loudly, not by skipping. Filtering a typo would produce "no by-hash
// entries for that algorithm", which is indistinguishable from having
// deliberately configured it away.
_, err := (&indexFiles{byHashAlgorithms: []string{"SHA256", "SHA3"}}).hashAlgorithms()
c.Check(err, ErrorMatches, `unknown Acquire-By-Hash algorithm "SHA3".*`)
_, err = (&indexFiles{byHashAlgorithms: []string{"sha256"}}).hashAlgorithms()
c.Check(err, NotNil)
}
+5 -1
View File
@@ -76,6 +76,10 @@ type PublishedRepo struct {
// Skip bz2 compression for index files
SkipBz2 bool
// AcquireByHashAlgorithms limits which hash algorithms get Acquire-By-Hash
// entries. Set per invocation from the config rather than persisted, the
// same way SkipBz2 is. Empty means all four.
AcquireByHashAlgorithms []string
// True if repo is being re-published
rePublishing bool
@@ -907,7 +911,7 @@ func (p *PublishedRepo) Publish(packagePool aptly.PackagePool, publishedStorageP
}
defer func() { _ = os.RemoveAll(tempDir) }()
indexes := newIndexFiles(publishedStorage, basePath, tempDir, suffix, p.AcquireByHash, p.SkipBz2)
indexes := newIndexFiles(publishedStorage, basePath, tempDir, suffix, p.AcquireByHash, p.AcquireByHashAlgorithms, p.SkipBz2)
legacyContentIndexes := map[string]*ContentsIndex{}
var count int64
+7
View File
@@ -71,6 +71,13 @@ The legacy json configuration is still supported (and also supports comments):
// empty array defaults to all available architectures
"architectures": [],
// Hash algorithms to write Acquire\-By\-Hash entries for, when a publish
// has Acquire\-By\-Hash enabled. Each algorithm costs a full set of index
// copies on every publish, which is felt most on S3\-like storage where
// every write is a round trip. An empty array defaults to all four.
// Modern apt uses SHA256; MD5Sum and SHA1 are deprecated.
"acquireByHashAlgorithms": ["MD5Sum", "SHA1", "SHA256", "SHA512"],
// Follow contents of `Suggests:` field when processing dependencies for the package
"dependencyFollowSuggests": false,
+7
View File
@@ -60,6 +60,13 @@ The legacy json configuration is still supported (and also supports comments):
// empty array defaults to all available architectures
"architectures": [],
// Hash algorithms to write Acquire-By-Hash entries for, when a publish
// has Acquire-By-Hash enabled. Each algorithm costs a full set of index
// copies on every publish, which is felt most on S3-like storage where
// every write is a round trip. An empty array defaults to all four.
// Modern apt uses SHA256; MD5Sum and SHA1 are deprecated.
"acquireByHashAlgorithms": ["MD5Sum", "SHA1", "SHA256", "SHA512"],
// Follow contents of `Suggests:` field when processing dependencies for the package
"dependencyFollowSuggests": false,
+6
View File
@@ -19,6 +19,11 @@ type ConfigStructure struct { // nolint: maligned
LogFormat string `json:"logFormat" yaml:"log_format"`
DatabaseOpenAttempts int `json:"databaseOpenAttempts" yaml:"database_open_attempts"`
Architectures []string `json:"architectures" yaml:"architectures"`
// Hash algorithms to write Acquire-By-Hash entries for. Each one costs a
// full set of index copies on every publish, which is felt most on S3-like
// storage where every write is a round trip. Empty means all four, which is
// what aptly did before this was configurable.
AcquireByHashAlgorithms []string `json:"acquireByHashAlgorithms" yaml:"acquire_by_hash_algorithms"`
SkipLegacyPool bool `json:"skipLegacyPool" yaml:"skip_legacy_pool"` // OBSOLETE
// Dependency following
@@ -247,6 +252,7 @@ type AzureEndpoint struct {
// Config is configuration for aptly, shared by all modules
var Config = ConfigStructure{
RootDir: filepath.Join(os.Getenv("HOME"), ".aptly"),
AcquireByHashAlgorithms: []string{"MD5Sum", "SHA1", "SHA256", "SHA512"},
DownloadConcurrency: 4,
DownloadLimit: 0,
Downloader: "default",
+3
View File
@@ -83,6 +83,7 @@ func (s *ConfigSuite) TestSaveConfig(c *C) {
"logFormat": "json",
"databaseOpenAttempts": 5,
"architectures": null,
"acquireByHashAlgorithms": null,
"skipLegacyPool": false,
"dependencyFollowSuggests": false,
"dependencyFollowRecommends": false,
@@ -280,6 +281,7 @@ func (s *ConfigSuite) TestSaveYAML2Config(c *C) {
"log_format: \"\"\n"+
"database_open_attempts: 0\n"+
"architectures: []\n"+
"acquire_by_hash_algorithms: []\n"+
"skip_legacy_pool: false\n"+
"dep_follow_suggests: false\n"+
"dep_follow_recommends: false\n"+
@@ -340,6 +342,7 @@ database_open_attempts: 10
architectures:
- amd64
- arm64
acquire_by_hash_algorithms: []
skip_legacy_pool: true
dep_follow_suggests: true
dep_follow_recommends: true