From ada163662aaebb41d7c16bd7c8e1dcbefb6d2481 Mon Sep 17 00:00:00 2001 From: Martin Simon Date: Mon, 31 Aug 2026 11:34:01 +0200 Subject: [PATCH 1/3] 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. --- api/publish.go | 9 ++++++ cmd/publish_snapshot.go | 3 ++ cmd/publish_switch.go | 3 ++ cmd/publish_update.go | 3 ++ deb/index_files.go | 64 ++++++++++++++++++++++++++++++++++++++--- deb/index_files_test.go | 49 +++++++++++++++++++++++++++++++ deb/publish.go | 6 +++- man/aptly.1 | 7 +++++ man/aptly.1.ronn.tmpl | 7 +++++ utils/config.go | 6 ++++ utils/config_test.go | 3 ++ 11 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 deb/index_files_test.go diff --git a/api/publish.go b/api/publish.go index 808596807..151b52181 100644 --- a/api/publish.go +++ b/api/publish.go @@ -384,6 +384,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) @@ -575,6 +578,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) @@ -1242,6 +1248,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) diff --git a/cmd/publish_snapshot.go b/cmd/publish_snapshot.go index 7be5a38ab..a7d238a97 100644 --- a/cmd/publish_snapshot.go +++ b/cmd/publish_snapshot.go @@ -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) diff --git a/cmd/publish_switch.go b/cmd/publish_switch.go index 20bf5caae..cba944654 100644 --- a/cmd/publish_switch.go +++ b/cmd/publish_switch.go @@ -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) diff --git a/cmd/publish_update.go b/cmd/publish_update.go index ab38d6496..d18ce6205 100644 --- a/cmd/publish_update.go +++ b/cmd/publish_update.go @@ -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) diff --git a/deb/index_files.go b/deb/index_files.go index 88c3c7091..8ee0db0b7 100644 --- a/deb/index_files.go +++ b/deb/index_files.go @@ -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, } } diff --git a/deb/index_files_test.go b/deb/index_files_test.go new file mode 100644 index 000000000..8ecad13b3 --- /dev/null +++ b/deb/index_files_test.go @@ -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) +} diff --git a/deb/publish.go b/deb/publish.go index 8ae71df94..f9aa6c85a 100644 --- a/deb/publish.go +++ b/deb/publish.go @@ -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 diff --git a/man/aptly.1 b/man/aptly.1 index 67351d7a7..893f03013 100644 --- a/man/aptly.1 +++ b/man/aptly.1 @@ -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, diff --git a/man/aptly.1.ronn.tmpl b/man/aptly.1.ronn.tmpl index 2119e40a5..95173bcf8 100644 --- a/man/aptly.1.ronn.tmpl +++ b/man/aptly.1.ronn.tmpl @@ -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, diff --git a/utils/config.go b/utils/config.go index 1c148e4f1..a8231ca76 100644 --- a/utils/config.go +++ b/utils/config.go @@ -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 @@ -246,6 +251,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", diff --git a/utils/config_test.go b/utils/config_test.go index abc59e492..86fcd3e52 100644 --- a/utils/config_test.go +++ b/utils/config_test.go @@ -83,6 +83,7 @@ func (s *ConfigSuite) TestSaveConfig(c *C) { "logFormat": "json", "databaseOpenAttempts": 5, "architectures": null, + "acquireByHashAlgorithms": null, "skipLegacyPool": false, "dependencyFollowSuggests": false, "dependencyFollowRecommends": false, @@ -279,6 +280,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"+ @@ -338,6 +340,7 @@ database_open_attempts: 10 architectures: - amd64 - arm64 +acquire_by_hash_algorithms: [] skip_legacy_pool: true dep_follow_suggests: true dep_follow_recommends: true From 8f7e2998b374dbc769e48423cb42b1d44c161ed5 Mon Sep 17 00:00:00 2001 From: Martin Simon Date: Mon, 31 Aug 2026 11:58:20 +0200 Subject: [PATCH 2/3] Add the new config key to the config-show gold file ConfigShowTest snapshots the output of `aptly config show`, which now carries acquireByHashAlgorithms with its default. The other three system files mentioning "architectures" are inputs rather than expected output, which is why only this one test failed. --- system/t02_config/ConfigShowTest_gold | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/system/t02_config/ConfigShowTest_gold b/system/t02_config/ConfigShowTest_gold index 8f12e639a..d4ddd1652 100644 --- a/system/t02_config/ConfigShowTest_gold +++ b/system/t02_config/ConfigShowTest_gold @@ -4,6 +4,12 @@ "logFormat": "default", "databaseOpenAttempts": 10, "architectures": [], + "acquireByHashAlgorithms": [ + "MD5Sum", + "SHA1", + "SHA256", + "SHA512" + ], "skipLegacyPool": false, "dependencyFollowSuggests": false, "dependencyFollowRecommends": false, From 9187b904fb8091a848c623be23ac5ac087444073 Mon Sep 17 00:00:00 2001 From: Martin Simon Date: Mon, 31 Aug 2026 12:42:39 +0200 Subject: [PATCH 3/3] Add the new config key to the YAML config-show gold file Same reason as the JSON one: ConfigShowYAMLTest snapshots `aptly config show` in YAML form. Checked all three gold files under t02_config this time rather than the one CI named. CreateConfigTest_gold is a commented template rather than struct output, so it is unaffected, and it passes. --- system/t02_config/ConfigShowYAMLTest_gold | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/system/t02_config/ConfigShowYAMLTest_gold b/system/t02_config/ConfigShowYAMLTest_gold index 942e4233b..e2bacbfa2 100644 --- a/system/t02_config/ConfigShowYAMLTest_gold +++ b/system/t02_config/ConfigShowYAMLTest_gold @@ -3,6 +3,11 @@ log_level: debug log_format: default database_open_attempts: 10 architectures: [] +acquire_by_hash_algorithms: + - MD5Sum + - SHA1 + - SHA256 + - SHA512 skip_legacy_pool: false dep_follow_suggests: false dep_follow_recommends: false