Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/build-samples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,35 @@ jobs:
fi
done

# Regression test for #1944: multi-arch builds with Alpine signing keys
# This tests the scenario where Alpine Linux returns the same ETag for
# different architecture-specific signing keys, which caused cache collisions.
build-alpine-multiarch-keys:
name: alpine-multiarch-keys-regression
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version-file: 'go.mod'
check-latest: true
- name: Setup QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- run: |
make apko
# Build multi-arch image that triggers Alpine signing key ETag collision
./apko build ./examples/alpine-multiarch-keys.yaml alpine-test:latest /tmp/alpine-multiarch.tar --arch x86_64,aarch64

annotations:
name: annotations
runs-on: ubuntu-latest
Expand Down
20 changes: 20 additions & 0 deletions examples/alpine-multiarch-keys.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Regression test for #1944: multi-arch builds with Alpine signing keys
# This config triggered a bug where Alpine Linux returns the same ETag
# for different architecture-specific signing keys, causing cache collisions.
contents:
repositories:
- https://dl-cdn.alpinelinux.org/alpine/v3.22/main
- https://dl-cdn.alpinelinux.org/alpine/v3.22/community
packages:
- alpine-baselayout
- ca-certificates-bundle
- alpine-keys
- busybox

archs: [x86_64, aarch64]

environment:
PATH: /usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin

entrypoint:
command: /bin/sh
38 changes: 34 additions & 4 deletions pkg/apk/apk/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package apk

import (
"context"
"crypto/sha256"
"encoding/base32"
"fmt"
"io"
Expand Down Expand Up @@ -323,18 +324,34 @@ func (t *cacheTransport) fetchOffline(cacheFile string) (*http.Response, error)
return nil, fmt.Errorf("listing %q for offline cache: %w", cacheDir, err)
}

// Filter out directories, only consider files
// Compute the expected cache file name with URL hash for this specific file
// In offline mode, we need to find the cached entry for this exact URL
expectedSuffix := "-" + cacheFileHash(cacheFile)

// Determine the file extension
ext := ".etag"
if strings.HasSuffix(cacheFile, "APKINDEX.tar.gz") {
ext = ".tar.gz"
}

// Filter files that match this specific cacheFile (by URL hash suffix)
var files []os.DirEntry
for _, de := range des {
if !de.IsDir() {
if de.IsDir() {
continue
}
// Check if filename contains our URL hash and has correct extension
name := de.Name()
if strings.Contains(name, expectedSuffix) && strings.HasSuffix(name, ext) {
files = append(files, de)
}
}

if len(files) == 0 {
return nil, fmt.Errorf("no offline cached entries for %s", cacheDir)
return nil, fmt.Errorf("no offline cached entries for %s (looking for files with %s)", cacheFile, expectedSuffix)
}

// Pick the newest file (in case there are multiple versions with same URL hash)
newest, err := files[0].Info()
if err != nil {
return nil, err
Expand Down Expand Up @@ -363,6 +380,13 @@ func (t *cacheTransport) fetchOffline(cacheFile string) (*http.Response, error)
}, nil
}

// cacheFileHash computes a short hash from the cache file path
// to uniquely identify the URL in cache filenames.
func cacheFileHash(cacheFile string) string {
urlHash := sha256.Sum256([]byte(cacheFile))
return fmt.Sprintf("%x", urlHash[:4])
}

func cacheDirFromFile(cacheFile string) string {
if strings.HasSuffix(cacheFile, "APKINDEX.tar.gz") {
return filepath.Join(filepath.Dir(cacheFile), "APKINDEX")
Expand All @@ -381,7 +405,13 @@ func cacheFileFromEtag(cacheFile, etag string) (string, error) {
ext = ".tar.gz"
}

absPath, err := filepath.Abs(filepath.Join(cacheDir, etag+ext))
// Create a unique cache key by combining the etag with a hash of the URL.
// This prevents collisions when different URLs return the same ETag
// (e.g., Alpine Linux keys all have ETag "639a4604-320").
// The URL hash ensures each unique file gets its own cache entry.
cacheKey := fmt.Sprintf("%s-%s", etag, cacheFileHash(cacheFile))

absPath, err := filepath.Abs(filepath.Join(cacheDir, cacheKey+ext))
if err != nil {
return "", err
}
Expand Down
Loading
Loading