From 563e4eab2ea94352f8b7e053da7f31cc7106928d Mon Sep 17 00:00:00 2001 From: Martin Simon Date: Mon, 31 Aug 2026 11:17:35 +0200 Subject: [PATCH] s3: match the SymLink metadata key case-insensitively in ReadLink S3 lowercases user metadata keys. SymLink writes "SymLink" and ReadLink looks up output.Metadata["SymLink"], but the value comes back under "symlink", so the lookup misses and ReadLink returns an empty string with a nil error. To the caller that reads as "this object is not a link" rather than as a failure. deb.packageIndexByHash uses ReadLink to find the physical file its .old pointer refers to, and removes it before rotating: linkTarget, err = publishedStorage.ReadLink(oldIndexPath) if err == nil { _ = publishedStorage.Remove(linkTarget) } With "" returned the Remove is a no-op and its error is discarded, so the superseded by-hash index file is never deleted. The pointers rotate correctly and the physical files accumulate: one per index file per hash algorithm per publish, indefinitely. Filesystem backends are unaffected because they have real symlinks. Measured on a live R2-backed archive publishing three suites a few times a day: dists/ held 711 objects, of which 672 were by-hash and only 39 were current indexes, growing by about 48 objects per publish since Acquire-By-Hash was enabled. The existing TestSymLink cannot catch this, because it ends in c.Skip("copy not available in s3test") and never runs. The regression test added here writes the metadata with PutObject instead, so it needs no CopyObject support, and fails before this change with obtained string = "". --- s3/public.go | 11 ++++++++++- s3/public_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/s3/public.go b/s3/public.go index c35de425..1a3e3b51 100644 --- a/s3/public.go +++ b/s3/public.go @@ -576,5 +576,14 @@ func (storage *PublishedStorage) ReadLink(path string) (string, error) { return "", err } - return output.Metadata["SymLink"], nil + // S3 lowercases user metadata keys, so the value SymLink writes as + // "SymLink" is returned as "symlink" and a case-sensitive lookup finds + // nothing. Matched case-insensitively so either spelling resolves. + for key, value := range output.Metadata { + if strings.EqualFold(key, "SymLink") { + return value, nil + } + } + + return "", nil } diff --git a/s3/public_test.go b/s3/public_test.go index d857e64d..7f68e01d 100644 --- a/s3/public_test.go +++ b/s3/public_test.go @@ -409,6 +409,34 @@ func (s *PublishedStorageSuite) TestSymLink(c *C) { c.Skip("copy not available in s3test") } +func (s *PublishedStorageSuite) TestReadLinkMetadataKeyIsCaseInsensitive(c *C) { + // S3 lowercases user metadata keys, so a value written as "SymLink" comes + // back as "symlink". A case-sensitive map lookup therefore returns "" with + // a nil error, which reads as "this is not a link" rather than as failure. + // + // deb.packageIndexByHash relies on ReadLink to find the physical file its + // .old pointer refers to, and removes it before rotating. With "" returned + // the Remove is a no-op whose error is discarded, so every superseded + // by-hash index file is left behind. On a live S3-backed archive that is + // one leaked object per index per hash algorithm per publish, forever; + // filesystem backends are unaffected because they have real symlinks. + // + // Written with PutObject rather than SymLink because the test server has + // no CopyObject, which is why TestSymLink above is skipped and why this + // path was never covered. + _, err := s.storage.s3.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String("test"), + Key: aws.String("a/b.link"), + Body: bytes.NewReader([]byte("test")), + Metadata: map[string]string{"symlink": "a/b"}, + }) + c.Assert(err, IsNil) + + link, err := s.storage.ReadLink("a/b.link") + c.Check(err, IsNil) + c.Check(link, Equals, "a/b") +} + func (s *PublishedStorageSuite) TestFileExists(c *C) { s.PutFile(c, "a/b", []byte("test"))