Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package apko for openSUSE:Factory checked in at 2026-07-08 17:38:39 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/apko (Old) and /work/SRC/openSUSE:Factory/.apko.new.1982 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "apko" Wed Jul 8 17:38:39 2026 rev:124 rq:1364475 version:1.2.23 Changes: -------- --- /work/SRC/openSUSE:Factory/apko/apko.changes 2026-07-06 12:36:39.236418155 +0200 +++ /work/SRC/openSUSE:Factory/.apko.new.1982/apko.changes 2026-07-08 17:41:50.048545322 +0200 @@ -1,0 +2,7 @@ +Wed Jul 08 05:10:51 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.2.23: + * Add contents.runtime_keyring for runtime APK trust (#2297) + * Use flightCache for index cache implementation (#2175) + +------------------------------------------------------------------- Old: ---- apko-1.2.22.obscpio New: ---- apko-1.2.23.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ apko.spec ++++++ --- /var/tmp/diff_new_pack.UeLLrt/_old 2026-07-08 17:41:51.588598989 +0200 +++ /var/tmp/diff_new_pack.UeLLrt/_new 2026-07-08 17:41:51.588598989 +0200 @@ -17,7 +17,7 @@ Name: apko -Version: 1.2.22 +Version: 1.2.23 Release: 0 Summary: Build OCI images from APK packages directly without Dockerfile License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.UeLLrt/_old 2026-07-08 17:41:51.640600801 +0200 +++ /var/tmp/diff_new_pack.UeLLrt/_new 2026-07-08 17:41:51.644600940 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chainguard-dev/apko.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v1.2.22</param> + <param name="revision">refs/tags/v1.2.23</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.UeLLrt/_old 2026-07-08 17:41:51.672601916 +0200 +++ /var/tmp/diff_new_pack.UeLLrt/_new 2026-07-08 17:41:51.676602055 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/chainguard-dev/apko</param> <param name="changesrevision">861f83f69e6fa9114405a2f7bb5cf6585ad00421</param></service><service name="tar_scm"> <param name="url">https://github.com/chainguard-dev/apko.git</param> - <param name="changesrevision">f9189c2afb477dd0f7ecab00f3751f0888553c4b</param></service></servicedata> + <param name="changesrevision">9d4b52d913063421165620da75b7926bbe211321</param></service></servicedata> (No newline at EOF) ++++++ apko-1.2.22.obscpio -> apko-1.2.23.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/docs/apko_file.md new/apko-1.2.23/docs/apko_file.md --- old/apko-1.2.22/docs/apko_file.md 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/docs/apko_file.md 2026-07-07 16:15:27.000000000 +0200 @@ -113,6 +113,16 @@ Notice that you need to package name under `packages` with the label e.g `- alpine-baselayout@local`. - `packages` defines a list of alpine packages to install inside the image - `keyring` PGP keys to add to the keyring for verifying packages. + - `runtime_repositories` defines a list of alpine repositories that are written to + `/etc/apk/repositories` in the image but are not used at build time, so `apk add` in the + running container pulls from them. + - `runtime_keyring` defines public keys installed into `/etc/apk/keys` after package + resolution, so runtime `apk add` against `runtime_repositories` can verify packages from + mirrors that re-sign them. Each entry is an inline `{name, content}` object: `content` is a + PEM-encoded RSA public key, and `name` is the filename the key is written to, which must + match the filename the repository's APKINDEX signature references (`.SIGN.RSA256.<name>`). + These keys are a runtime trust anchor only and are never consulted during build-time + package resolution. ### Entrypoint top level element diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/examples/runtime-keyring.yaml new/apko-1.2.23/examples/runtime-keyring.yaml --- old/apko-1.2.22/examples/runtime-keyring.yaml 1970-01-01 01:00:00.000000000 +0100 +++ new/apko-1.2.23/examples/runtime-keyring.yaml 2026-07-07 16:15:27.000000000 +0200 @@ -0,0 +1,36 @@ +# Demonstrates contents.runtime_keyring: public keys installed into +# /etc/apk/keys *after* the build, so runtime `apk add` against a +# runtime_repositories mirror (which re-signs packages with its own key) +# verifies without --allow-untrusted. Runtime trust only — these keys are +# not used to verify packages during the build. +contents: + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + repositories: + - https://packages.wolfi.dev/os + packages: + - wolfi-base + # The repo whose packages the key above verifies at runtime. Seeded into + # /etc/apk/repositories in the image; not used at build time. + runtime_repositories: + - https://packages.example.com/os + # Each entry is an inline {name, content} public key; the name must match + # the filename the repository's APKINDEX signature references. + runtime_keyring: + - name: example-mirror.rsa.pub + content: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtfLcP/XCqh9zuL8J1sIu + ADYZt1xafrimFkOMHgldT+0Mpi3ORy6lf29Ccc+4Hfe2P/ImlB89THtIjMNGk58C + fZVod8z2cc52aV5YvNbFDxSoOPSc4D+IqL9bE6ICc0HS2kef1Rm6ZY1G8JoNs2zR + nquL6XyV7+gPC9KNhGi16f8qyhvMy3dkGPmqtXuI+j7p0XtDB1/e3xe+Mg+kQ47A + DiE2XB+9nbvOKOXuuUscerhEnvYPLRjDQECkRzLN76CPJsGwoBn7In/kMFecQuLD + LbYPdu/s8Pvz4wDurWOzUShbrF6Ka9ARRZVN57+4u6k+zuIi0XF5UCHicrjl6jYR + FQIDAQAB + -----END PUBLIC KEY----- + +cmd: /bin/sh -l + +archs: + - x86_64 + - aarch64 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/internal/gen-jsonschema/main.go new/apko-1.2.23/internal/gen-jsonschema/main.go --- old/apko-1.2.22/internal/gen-jsonschema/main.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/internal/gen-jsonschema/main.go 2026-07-07 16:15:27.000000000 +0200 @@ -28,6 +28,7 @@ log.Fatal(err) } schema := r.Reflect(types.ImageConfiguration{}) + b := new(bytes.Buffer) enc := json.NewEncoder(b) enc.SetIndent("", " ") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/apk/apk/cache.go new/apko-1.2.23/pkg/apk/apk/cache.go --- old/apko-1.2.22/pkg/apk/apk/cache.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/apk/apk/cache.go 2026-07-07 16:15:27.000000000 +0200 @@ -82,6 +82,17 @@ delete(f.cache, key) } +// ForgetFunc removes all keys for which fn returns true. +func (f *flightCache[K, V]) ForgetFunc(fn func(K) bool) { + f.mux.Lock() + defer f.mux.Unlock() + for k := range f.cache { + if fn(k) { + delete(f.cache, k) + } + } +} + type Cache struct { etagCache *sync.Map headFlight *singleflight.Group diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/apk/apk/cache_test.go new/apko-1.2.23/pkg/apk/apk/cache_test.go --- old/apko-1.2.22/pkg/apk/apk/cache_test.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/apk/apk/cache_test.go 2026-07-07 16:15:27.000000000 +0200 @@ -15,6 +15,7 @@ package apk import ( + "strings" "sync" "sync/atomic" "testing" @@ -100,3 +101,31 @@ require.EqualValues(t, 1, called.Load(), "Function should only be called once") } + +func TestFlightCacheForgetFunc(t *testing.T) { + s := newFlightCache[string, int]() + + for k, v := range map[string]int{"a-1": 1, "a-2": 2, "b-1": 3} { + _, err := s.Do(k, func() (int, error) { return v, nil }) + require.NoError(t, err) + } + + // Forget all keys starting with "a-". + s.ForgetFunc(func(k string) bool { + return strings.HasPrefix(k, "a-") + }) + + // "a-*" keys should be evicted, so new values are computed. + r, err := s.Do("a-1", func() (int, error) { return 100, nil }) + require.NoError(t, err) + require.Equal(t, 100, r) + + r, err = s.Do("a-2", func() (int, error) { return 200, nil }) + require.NoError(t, err) + require.Equal(t, 200, r) + + // "b-1" should still be cached. + r, err = s.Do("b-1", func() (int, error) { return 999, nil }) + require.NoError(t, err) + require.Equal(t, 3, r, "b-1 should still return the cached value") +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/apk/apk/index.go new/apko-1.2.23/pkg/apk/apk/index.go --- old/apko-1.2.22/pkg/apk/apk/index.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/apk/apk/index.go 2026-07-07 16:15:27.000000000 +0200 @@ -55,49 +55,21 @@ // We just hold the parsed index in memory rather than re-parsing it every time, // which requires gunzipping, which is (somewhat) expensive. var globalIndexCache = &indexCache{ - modtimes: map[string]time.Time{}, - urlToEtag: map[string]string{}, + indexes: newFlightCache[cacheKey, NamedIndex](), + modtimes: map[string]time.Time{}, } -type indexResult struct { - idx NamedIndex - err error +type cacheKey struct { + url string + etag string // Only used for remote indexes. } type indexCache struct { - // For remote indexes. - onces sync.Map - urlToEtag map[string]string - etagMu sync.Mutex // guards urlToEtag + indexes *flightCache[cacheKey, NamedIndex] // For local indexes. sync.Mutex modtimes map[string]time.Time - - // etag|filename -> indexResult - indexes sync.Map -} - -func (i *indexCache) forget(key string) { - i.onces.Delete(key) - i.indexes.Delete(key) -} - -func (i *indexCache) store(key string, idx NamedIndex, err error) { - i.indexes.Store(key, indexResult{ - idx: idx, - err: err, - }) -} - -func (i *indexCache) load(key string) (NamedIndex, error) { - v, ok := i.indexes.Load(key) - if !ok { - return nil, fmt.Errorf("indexCache did not see key %q after writing it", key) - } - result := v.(indexResult) - - return result.idx, result.err } func (i *indexCache) get(ctx context.Context, repoName, repoURL string, keys map[string][]byte, arch string, opts *indexOpts) (NamedIndex, error) { @@ -159,30 +131,21 @@ return fetchAndParse(etag) } - key := fmt.Sprintf("%s@%s", u, etag) - - once, _ := i.onces.LoadOrStore(key, &sync.Once{}) - once.(*sync.Once).Do(func() { - // If we've seen this URL before, delete any references to old indexes so we can GC them. - // Lock reads/writes to the map, without blocking the fetchAndParse goroutine. - i.etagMu.Lock() - prev, ok := i.urlToEtag[u] - if ok { - prevKey := fmt.Sprintf("%s@%s", u, prev) - i.forget(prevKey) - } - i.etagMu.Unlock() - - idx, err := fetchAndParse(etag) - i.store(key, idx, err) + key := cacheKey{url: u, etag: etag} + idx, err := i.indexes.Do(key, func() (NamedIndex, error) { + return fetchAndParse(etag) + }) - // Record the current etag for this URL so we can GC it later. - i.etagMu.Lock() - i.urlToEtag[u] = etag - i.etagMu.Unlock() + // Remove any stale entries with the same URL but a different etag. + // This races with concurrent callers that may have a different etag: a + // slower goroutine with an older etag could evict a newer entry. This is + // harmless — the next call will see the current etag via HEAD, re-fetch, + // and repopulate the cache. + i.indexes.ForgetFunc(func(k cacheKey) bool { + return k.url == u && k.etag != etag }) - return i.load(key) + return idx, err } else { i.Lock() defer i.Unlock() @@ -193,24 +156,28 @@ return nil, fmt.Errorf("stat: %w", err) } + key := cacheKey{url: u} + + // Evict the cached entry if the file has changed since we last saw it. + // On first access, ok is false and Do below handles the cache miss. mod := stat.ModTime() before, ok := i.modtimes[u] - if !ok || mod.After(before) { + if ok && mod.After(before) { + i.indexes.Forget(key) + } + i.modtimes[u] = mod + + return i.indexes.Do(key, func() (NamedIndex, error) { b, err := os.ReadFile(u) if err != nil { return nil, fmt.Errorf("reading file: %w", err) } - // If this is the first time or it has changed since the last time... idx, err := parseRepositoryIndex(ctx, u, keys, arch, b, opts) if err != nil { - i.store(u, nil, err) - } else { - i.store(u, NewNamedRepositoryWithIndex(repoName, repoRef.WithIndex(idx)), nil) + return nil, err } - i.modtimes[u] = mod - } - - return i.load(u) + return NewNamedRepositoryWithIndex(repoName, repoRef.WithIndex(idx)), nil + }) } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/build_implementation.go new/apko-1.2.23/pkg/build/build_implementation.go --- old/apko-1.2.22/pkg/build/build_implementation.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/build/build_implementation.go 2026-07-07 16:15:27.000000000 +0200 @@ -198,6 +198,10 @@ return nil, fmt.Errorf("failed to install certificates: %w", err) } + if err := bc.installRuntimeKeyring(ctx); err != nil { + return nil, fmt.Errorf("failed to install runtime keyring: %w", err) + } + if err := bc.s6.WriteSupervisionTree(ctx, bc.ic.Entrypoint.Services); err != nil { return nil, fmt.Errorf("failed to write supervision tree: %w", err) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/runtime_keyring.go new/apko-1.2.23/pkg/build/runtime_keyring.go --- old/apko-1.2.22/pkg/build/runtime_keyring.go 1970-01-01 01:00:00.000000000 +0100 +++ new/apko-1.2.23/pkg/build/runtime_keyring.go 2026-07-07 16:15:27.000000000 +0200 @@ -0,0 +1,139 @@ +// Copyright 2025 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "bytes" + "cmp" + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io/fs" + "path/filepath" + "slices" + + "go.opentelemetry.io/otel" + + "chainguard.dev/apko/pkg/build/types" +) + +// Directory apk reads trusted signing keys from. Keys placed here let runtime +// `apk add` verify packages from runtime repositories. We are using these for +// fs.FS so should omit the leading / +const apkKeysDir = "etc/apk/keys" + +// installRuntimeKeyring writes contents.runtime_keyring's inline public keys to +// /etc/apk/keys. It runs after package installation (post-FixateWorld), so the +// keys are a runtime trust anchor only and never participate in build-time +// package resolution. +// +// Keys are content-bearing in the config, so the build stays reproducible: +// identical key content and names produce identical output, with no host paths +// leaking into the image or the locked configuration. +func (bc *Context) installRuntimeKeyring(ctx context.Context) error { + _, span := otel.Tracer("apko").Start(ctx, "installRuntimeKeyring") + defer span.End() + + if len(bc.ic.Contents.RuntimeKeyring) == 0 { + return nil + } + + builtTime, err := bc.GetBuildDateEpoch() + if err != nil { + return fmt.Errorf("failed to get build date epoch: %w", err) + } + + if err := bc.fs.MkdirAll(apkKeysDir, 0o755); err != nil { + return fmt.Errorf("failed to create apk keys directory: %w", err) + } + + // Sort by name for a deterministic write order. Duplicate names never reach + // this point: Validate rejects them on the merged configuration (includes + // are folded in at parse time), so duplicate policy lives there, not here. + keys := slices.Clone(bc.ic.Contents.RuntimeKeyring) + slices.SortFunc(keys, func(a, b types.RuntimeKeyringEntry) int { return cmp.Compare(a.Name, b.Name) }) + + for _, key := range keys { + encoded, err := parsePublicKey(key.Content) + if err != nil { + return fmt.Errorf("failed to parse runtime keyring entry %q: %w", key.Name, err) + } + + // Name is validated against certNameRegex during configuration + // validation, so it can't escape the directory. + keyPath := filepath.Join(apkKeysDir, key.Name) + + // Refuse to overwrite an existing key. By this point InitKeyring has + // already installed the distro trust roots (e.g. wolfi-signing.rsa.pub), + // so a name collision would silently replace a packaged key and change + // runtime verification behaviour. Lstat, not Stat: a symlink at this + // path (even dangling) must count as existing, or the write below would + // follow it and land the key outside /etc/apk/keys. + // + // Note: on base-image builds inherited keys live in a lower layer, not + // bc.fs, so this only catches collisions within the layer being built. + if _, err := bc.fs.Lstat(keyPath); err == nil { + return fmt.Errorf("runtime keyring entry %q collides with an existing /etc/apk/keys entry", key.Name) + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to stat %s: %w", keyPath, err) + } + + if err := bc.fs.WriteFile(keyPath, encoded, 0o644); err != nil { + return fmt.Errorf("failed to write runtime keyring entry %s: %w", keyPath, err) + } + if err := bc.fs.Chtimes(keyPath, builtTime, builtTime); err != nil { + return fmt.Errorf("failed to change times on runtime keyring entry %s: %w", keyPath, err) + } + } + + return nil +} + +// parsePublicKey validates that pemData is exactly one PEM-encoded RSA public +// key and returns it re-encoded. Re-encoding drops any trailing bytes around the +// block, keeping the written file canonical and reproducible. +func parsePublicKey(pemData string) ([]byte, error) { + if pemData == "" { + return nil, fmt.Errorf("no key data provided") + } + + block, rest := pem.Decode([]byte(pemData)) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + if block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("expected PUBLIC KEY block, got %s", block.Type) + } + if next, _ := pem.Decode(rest); next != nil { + return nil, fmt.Errorf("multiple PEM blocks found; only one is allowed") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + if _, ok := pub.(*rsa.PublicKey); !ok { + return nil, fmt.Errorf("expected an RSA public key, got %T", pub) + } + + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{Type: "PUBLIC KEY", Bytes: block.Bytes}); err != nil { + return nil, fmt.Errorf("failed to re-encode public key: %w", err) + } + return buf.Bytes(), nil +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/runtime_keyring_test.go new/apko-1.2.23/pkg/build/runtime_keyring_test.go --- old/apko-1.2.22/pkg/build/runtime_keyring_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/apko-1.2.23/pkg/build/runtime_keyring_test.go 2026-07-07 16:15:27.000000000 +0200 @@ -0,0 +1,316 @@ +// Copyright 2025 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + apkfs "chainguard.dev/apko/pkg/apk/fs" + "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/options" + + "github.com/google/go-cmp/cmp" +) + +// rsaPublicKeyPEM returns a PEM-encoded ("PUBLIC KEY") RSA public key. +func rsaPublicKeyPEM(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating RSA key: %v", err) + } + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("marshaling RSA public key: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) +} + +func ecPublicKeyPEM(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating EC key: %v", err) + } + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("marshaling EC public key: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) +} + +func inlineKey(name, content string) types.RuntimeKeyringEntry { + return types.RuntimeKeyringEntry{Name: name, Content: content} +} + +func TestInstallRuntimeKeyring(t *testing.T) { + epoch := time.Unix(1337, 0) + keyPEM := rsaPublicKeyPEM(t) + + tests := []struct { + name string + keys []types.RuntimeKeyringEntry + // existing files under /etc/apk/keys, modelling keys InitKeyring + // installed before installRuntimeKeyring runs. + existing map[string][]byte + wantErr bool + wantKeys []string // basenames expected under /etc/apk/keys + }{{ + name: "empty is a no-op", + keys: nil, + }, { + name: "writes a single key", + keys: []types.RuntimeKeyringEntry{inlineKey("mirror.rsa.pub", keyPEM)}, + wantKeys: []string{"mirror.rsa.pub"}, + }, { + name: "writes alongside an existing distro key", + keys: []types.RuntimeKeyringEntry{inlineKey("mirror.rsa.pub", keyPEM)}, + existing: map[string][]byte{ + filepath.Join(apkKeysDir, "wolfi-signing.rsa.pub"): []byte("existing-distro-key"), + }, + wantKeys: []string{"mirror.rsa.pub", "wolfi-signing.rsa.pub"}, + }, { + name: "refuses to overwrite an existing key", + keys: []types.RuntimeKeyringEntry{inlineKey("wolfi-signing.rsa.pub", keyPEM)}, + existing: map[string][]byte{ + filepath.Join(apkKeysDir, "wolfi-signing.rsa.pub"): []byte("existing-distro-key"), + }, + wantErr: true, + }, { + name: "same name, different content collides", + keys: []types.RuntimeKeyringEntry{inlineKey("dup.rsa.pub", keyPEM), inlineKey("dup.rsa.pub", rsaPublicKeyPEM(t))}, + wantErr: true, + }, { + name: "rejects a non-RSA key", + keys: []types.RuntimeKeyringEntry{inlineKey("ec.rsa.pub", ecPublicKeyPEM(t))}, + wantErr: true, + }, { + name: "rejects malformed PEM", + keys: []types.RuntimeKeyringEntry{inlineKey("bad.rsa.pub", "not a pem block")}, + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", fmt.Sprintf("%d", epoch.Unix())) + fsys := apkfs.NewMemFS() + for path, content := range tt.existing { + if err := fsys.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir for existing %s: %v", path, err) + } + if err := fsys.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("write existing %s: %v", path, err) + } + } + + bc := &Context{ + o: options.Options{SourceDateEpoch: epoch}, + ic: types.ImageConfiguration{Contents: types.ImageContents{RuntimeKeyring: tt.keys}}, + fs: fsys, + } + + err := bc.installRuntimeKeyring(t.Context()) + if (err != nil) != tt.wantErr { + t.Fatalf("installRuntimeKeyring() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + for _, name := range tt.wantKeys { + path := filepath.Join(apkKeysDir, name) + stat, err := fsys.Stat(path) + if err != nil { + t.Fatalf("expected key %s: %v", path, err) + } + // Distro keys placed by setup keep their original mtime; only + // installed runtime keys get the deterministic epoch. + if _, isSetup := tt.existing[path]; !isSetup { + if mode := stat.Mode().Perm(); mode != 0o644 { + t.Errorf("key %s mode = %v, want 0644", name, mode) + } + if !stat.ModTime().Equal(epoch) { + t.Errorf("key %s mtime = %v, want %v", name, stat.ModTime(), epoch) + } + } + } + }) + } +} + +// A symlink at the key's path must never be silently written through — that +// would land the key outside /etc/apk/keys. The existence check uses Lstat so +// a symlink (even dangling, which Stat reports as not-exist) counts as a +// collision on a real filesystem. MemFS's Lstat follows links (a known quirk, +// documented in memfs.go), so there a dangling link surfaces as a write error +// instead — either way the install fails and nothing is written through the +// link. +func TestInstallRuntimeKeyringRefusesSymlink(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1337") + fsys := apkfs.NewMemFS() + if err := fsys.MkdirAll(apkKeysDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := fsys.Symlink("/outside/target.rsa.pub", filepath.Join(apkKeysDir, "mirror.rsa.pub")); err != nil { + t.Fatalf("symlink: %v", err) + } + + bc := &Context{ + o: options.Options{SourceDateEpoch: time.Unix(1337, 0)}, + ic: types.ImageConfiguration{Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{inlineKey("mirror.rsa.pub", rsaPublicKeyPEM(t))}, + }}, + fs: fsys, + } + + if err := bc.installRuntimeKeyring(t.Context()); err == nil { + t.Fatal("installRuntimeKeyring() = nil, want an error for a symlinked key path") + } + if _, err := fsys.Stat("/outside/target.rsa.pub"); err == nil { + t.Fatal("key content was written through the symlink outside /etc/apk/keys") + } +} + +// Runtime keys are content-bearing and written deterministically, so two runs +// with identical input produce byte-identical files. +func TestInstallRuntimeKeyringDeterministic(t *testing.T) { + epoch := time.Unix(1337, 0) + keyPEM := rsaPublicKeyPEM(t) + keys := []types.RuntimeKeyringEntry{inlineKey("b.rsa.pub", keyPEM), inlineKey("a.rsa.pub", keyPEM)} + + run := func() map[string][]byte { + t.Setenv("SOURCE_DATE_EPOCH", fmt.Sprintf("%d", epoch.Unix())) + fsys := apkfs.NewMemFS() + bc := &Context{ + o: options.Options{SourceDateEpoch: epoch}, + ic: types.ImageConfiguration{Contents: types.ImageContents{RuntimeKeyring: keys}}, + fs: fsys, + } + if err := bc.installRuntimeKeyring(t.Context()); err != nil { + t.Fatalf("installRuntimeKeyring: %v", err) + } + out := map[string][]byte{} + for _, name := range []string{"a.rsa.pub", "b.rsa.pub"} { + data, err := fsys.ReadFile(filepath.Join(apkKeysDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + out[name] = data + } + return out + } + + if diff := cmp.Diff(run(), run()); diff != "" { + t.Errorf("non-deterministic output (-run1 +run2):\n%s", diff) + } +} + +// Runtime keys live in a distinct field from the build keyring (contents.keyring), +// which is the only thing InitKeyring consumes — so they are never build-trusted. +func TestRuntimeKeyringDisjointFromBuildKeyring(t *testing.T) { + ic := types.ImageConfiguration{Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{inlineKey("mirror.rsa.pub", rsaPublicKeyPEM(t))}, + }} + if len(ic.Contents.Keyring) != 0 { + t.Errorf("runtime_keyring leaked into contents.keyring (the InitKeyring source): %v", ic.Contents.Keyring) + } +} + +func TestParsePublicKey(t *testing.T) { + keyPEM := rsaPublicKeyPEM(t) + + tests := []struct { + name string + content string + wantErr bool + }{ + {name: "valid RSA public key", content: keyPEM}, + {name: "empty", content: "", wantErr: true}, + {name: "not a PEM block", content: "garbage", wantErr: true}, + {name: "EC key rejected", content: ecPublicKeyPEM(t), wantErr: true}, + {name: "certificate block rejected", content: testCertPEM, wantErr: true}, + {name: "multiple blocks rejected", content: keyPEM + keyPEM, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePublicKey(tt.content) + if (err != nil) != tt.wantErr { + t.Fatalf("parsePublicKey() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if block, _ := pem.Decode(got); block == nil || block.Type != "PUBLIC KEY" { + t.Errorf("re-encoded output is not a PUBLIC KEY PEM block") + } + }) + } +} + +// Re-encoding canonicalizes the key: trailing text around the block is dropped. +func TestParsePublicKeyDropsTrailingBytes(t *testing.T) { + keyPEM := rsaPublicKeyPEM(t) + got, err := parsePublicKey(keyPEM + "\ntrailing junk that is not pem\n") + if err != nil { + t.Fatalf("parsePublicKey: %v", err) + } + if strings.Contains(string(got), "trailing junk") { + t.Errorf("re-encoded key retained trailing bytes:\n%s", got) + } +} + +// The DoD invariant: inline runtime keys are content-addressed. The serialized +// configuration carries the key content (so identity is reproducible) but never +// a host path, temp dir, or file:// URI. +func TestRuntimeKeyringSerializationCarriesContentNotPath(t *testing.T) { + keyPEM := rsaPublicKeyPEM(t) + ic := types.ImageConfiguration{Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{inlineKey("mirror.rsa.pub", keyPEM)}, + }} + + a, err := json.Marshal(ic) + if err != nil { + t.Fatalf("marshal: %v", err) + } + b, err := json.Marshal(ic) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(a) != string(b) { + t.Errorf("identical configs marshaled differently:\n%s\n%s", a, b) + } + + s := string(a) + if !strings.Contains(s, "runtime_keyring") || !strings.Contains(s, "BEGIN PUBLIC KEY") { + t.Errorf("serialized config missing runtime keyring content:\n%s", s) + } + for _, banned := range []string{"file://", "/tmp/", "/etc/apk/keys", string(filepath.Separator) + "var" + string(filepath.Separator)} { + if strings.Contains(s, banned) { + t.Errorf("serialized config leaked a path substring %q:\n%s", banned, s) + } + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/types/image_configuration.go new/apko-1.2.23/pkg/build/types/image_configuration.go --- old/apko-1.2.22/pkg/build/types/image_configuration.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/build/types/image_configuration.go 2026-07-07 16:15:27.000000000 +0200 @@ -40,7 +40,7 @@ // directory traversal. var certNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+[a-zA-Z0-9_.-]*$`) -// Attempt to probe an upstream VCS URL if known. +// ProbeVCSUrl attempts to probe an upstream VCS URL if known. func (ic *ImageConfiguration) ProbeVCSUrl(ctx context.Context, imageConfigPath string) { log := clog.FromContext(ctx) @@ -174,6 +174,9 @@ func (i *ImageContents) MergeInto(target *ImageContents) error { target.Keyring = slices.Concat(i.Keyring, target.Keyring) + // Load-bearing: the locked config is produced via input.MergeInto, so a + // field omitted here is silently dropped from the dedup key and /etc/apko.json. + target.RuntimeKeyring = slices.Concat(i.RuntimeKeyring, target.RuntimeKeyring) target.BuildRepositories = slices.Concat(i.BuildRepositories, target.BuildRepositories) target.RuntimeOnlyRepositories = slices.Concat(i.RuntimeOnlyRepositories, target.RuntimeOnlyRepositories) target.Repositories = slices.Concat(i.Repositories, target.Repositories) @@ -244,6 +247,22 @@ } } } + seen := make(map[string]struct{}, len(ic.Contents.RuntimeKeyring)) + for _, key := range ic.Contents.RuntimeKeyring { + if key.Name == "" { + return fmt.Errorf("configured runtime_keyring entry has no name") + } + if key.Content == "" { + return fmt.Errorf("runtime_keyring entry %q has no content", key.Name) + } + if !certNameRegex.MatchString(key.Name) { + return fmt.Errorf("runtime_keyring entry %q has an invalid name, it must match %s", key.Name, certNameRegex.String()) + } + if _, dup := seen[key.Name]; dup { + return fmt.Errorf("runtime_keyring entry %q is a duplicate", key.Name) + } + seen[key.Name] = struct{}{} + } return nil } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/types/image_configuration_test.go new/apko-1.2.23/pkg/build/types/image_configuration_test.go --- old/apko-1.2.22/pkg/build/types/image_configuration_test.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/build/types/image_configuration_test.go 2026-07-07 16:15:27.000000000 +0200 @@ -312,6 +312,56 @@ }}, }, }, + }, { + name: "runtime_keyring name with periods is valid", + configuration: types.ImageConfiguration{ + Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: "mirror.rsa.pub", Content: "test"}}, + }, + }, + }, { + name: "runtime_keyring name starting with period", + configuration: types.ImageConfiguration{ + Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: ".mirror.rsa.pub", Content: "test"}}, + }, + }, + expectError: `runtime_keyring entry ".mirror.rsa.pub" has an invalid name, it must match ^[a-zA-Z0-9_-]+[a-zA-Z0-9_.-]*$`, + }, { + name: "runtime_keyring name with path separator", + configuration: types.ImageConfiguration{ + Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: "subdir/mirror.rsa.pub", Content: "test"}}, + }, + }, + expectError: `runtime_keyring entry "subdir/mirror.rsa.pub" has an invalid name, it must match ^[a-zA-Z0-9_-]+[a-zA-Z0-9_.-]*$`, + }, { + name: "duplicate runtime_keyring name", + configuration: types.ImageConfiguration{ + Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{ + {Name: "mirror.rsa.pub", Content: "test"}, + {Name: "mirror.rsa.pub", Content: "test"}, + }, + }, + }, + expectError: `runtime_keyring entry "mirror.rsa.pub" is a duplicate`, + }, { + name: "runtime_keyring entry without name", + configuration: types.ImageConfiguration{ + Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{{Content: "test"}}, + }, + }, + expectError: `configured runtime_keyring entry has no name`, + }, { + name: "runtime_keyring entry without content", + configuration: types.ImageConfiguration{ + Contents: types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: "mirror.rsa.pub"}}, + }, + }, + expectError: `runtime_keyring entry "mirror.rsa.pub" has no content`, }} for _, tt := range tests { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/types/runtime_keyring_entry_test.go new/apko-1.2.23/pkg/build/types/runtime_keyring_entry_test.go --- old/apko-1.2.22/pkg/build/types/runtime_keyring_entry_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/apko-1.2.23/pkg/build/types/runtime_keyring_entry_test.go 2026-07-07 16:15:27.000000000 +0200 @@ -0,0 +1,102 @@ +// Copyright 2025 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "gopkg.in/yaml.v3" + + "chainguard.dev/apko/pkg/build/types" +) + +// TestRuntimeKeyringEntryStrictDecode mirrors the configuration loader's +// strict decoding (KnownFields, image_configuration.go): unknown keys inside +// a runtime_keyring entry must be rejected, and well-formed entries decode. +func TestRuntimeKeyringEntryStrictDecode(t *testing.T) { + type holder struct { + RuntimeKeyring []types.RuntimeKeyringEntry `yaml:"runtime_keyring"` + } + + tests := []struct { + name string + yaml string + want []types.RuntimeKeyringEntry + wantErr bool + }{{ + name: "name and content decode", + yaml: "runtime_keyring:\n - name: mirror.rsa.pub\n content: PEM", + want: []types.RuntimeKeyringEntry{{Name: "mirror.rsa.pub", Content: "PEM"}}, + }, { + name: "unknown key rejected by strict decoding", + yaml: "runtime_keyring:\n - name: mirror.rsa.pub\n content: PEM\n uri: https://example.com/k.rsa.pub", + wantErr: true, + }, { + name: "scalar entry rejected (no URI form)", + yaml: "runtime_keyring:\n - https://example.com/k.rsa.pub", + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := yaml.NewDecoder(strings.NewReader(tt.yaml)) + dec.KnownFields(true) + var h holder + err := dec.Decode(&h) + if (err != nil) != tt.wantErr { + t.Fatalf("Decode error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if diff := cmp.Diff(tt.want, h.RuntimeKeyring); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + } + }) + } +} + +// The JSON wire shape feeds /etc/apko.json and the locked-config dedup key: +// entries must serialize as {name, content} objects carrying the key bytes. +func TestRuntimeKeyringEntryMarshalJSON(t *testing.T) { + b, err := json.Marshal(types.ImageContents{ + RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: "m.rsa.pub", Content: "PEM"}}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"runtime_keyring":[{"name":"m.rsa.pub","content":"PEM"}]}` + if got := string(b); got != want { + t.Errorf("MarshalJSON\n got: %s\nwant: %s", got, want) + } +} + +// MergeInto must concat RuntimeKeyring — it is the path that builds the locked +// config (via input.MergeInto) and merges includes, so omission silently drops +// the field from the dedup key and from include-derived builds. +func TestImageContentsMergeIntoRuntimeKeyring(t *testing.T) { + source := types.ImageContents{RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: "a.rsa.pub", Content: "A"}}} + target := types.ImageContents{RuntimeKeyring: []types.RuntimeKeyringEntry{{Name: "b.rsa.pub", Content: "B"}}} + if err := source.MergeInto(&target); err != nil { + t.Fatalf("MergeInto: %v", err) + } + want := []types.RuntimeKeyringEntry{{Name: "a.rsa.pub", Content: "A"}, {Name: "b.rsa.pub", Content: "B"}} + if diff := cmp.Diff(want, target.RuntimeKeyring); diff != "" { + t.Errorf("RuntimeKeyring not concatenated (-want +got):\n%s", diff) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/types/schema.json new/apko-1.2.23/pkg/build/types/schema.json --- old/apko-1.2.22/pkg/build/types/schema.json 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/build/types/schema.json 2026-07-07 16:15:27.000000000 +0200 @@ -207,6 +207,13 @@ "type": "array", "description": "A list of public keys used to verify the desired repositories" }, + "runtime_keyring": { + "items": { + "$ref": "#/$defs/RuntimeKeyringEntry" + }, + "type": "array", + "description": "APK signing public keys installed into /etc/apk/keys after package\nresolution, so runtime `apk add` against runtime_repositories can verify\nre-signed packages. A runtime trust anchor only — not consulted during\nbuild-time package resolution. Each entry is an inline {name, content}\npublic key." + }, "packages": { "items": { "type": "string" @@ -292,6 +299,21 @@ "additionalProperties": false, "type": "object" }, + "RuntimeKeyringEntry": { + "properties": { + "name": { + "type": "string", + "description": "Required: the filename the key is written to under /etc/apk/keys. Must\nmatch the filename the repository's APKINDEX signature references\n(.SIGN.RSA256.\u003cname\u003e), or apk will not find the key at runtime." + }, + "content": { + "type": "string", + "description": "Required: the PEM-encoded RSA public key content." + } + }, + "additionalProperties": false, + "type": "object", + "description": "RuntimeKeyringEntry is a single inline APK signing public key, mirroring AdditionalCertificateEntry." + }, "User": { "properties": { "username": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.22/pkg/build/types/types.go new/apko-1.2.23/pkg/build/types/types.go --- old/apko-1.2.22/pkg/build/types/types.go 2026-07-03 18:11:53.000000000 +0200 +++ new/apko-1.2.23/pkg/build/types/types.go 2026-07-07 16:15:27.000000000 +0200 @@ -126,6 +126,12 @@ Repositories []string `json:"repositories,omitempty" yaml:"repositories,omitempty"` // A list of public keys used to verify the desired repositories Keyring []string `json:"keyring,omitempty" yaml:"keyring,omitempty"` + // APK signing public keys installed into /etc/apk/keys after package + // resolution, so runtime `apk add` against runtime_repositories can verify + // re-signed packages. A runtime trust anchor only — not consulted during + // build-time package resolution. Each entry is an inline {name, content} + // public key. + RuntimeKeyring []RuntimeKeyringEntry `json:"runtime_keyring,omitempty" yaml:"runtime_keyring,omitempty"` // A list of packages to include in the image Packages []string `json:"packages,omitempty" yaml:"packages,omitempty"` // Optional: Base image to build on top of. Warning: Experimental. @@ -467,3 +473,16 @@ // containing CA certificate files to be assembled into the system CA bundle. Providers []string `json:"providers,omitempty" yaml:"providers,omitempty"` } + +// RuntimeKeyringEntry is a single inline APK signing public key, mirroring +// AdditionalCertificateEntry. Keys are content-bearing by design: the key bytes +// live in the configuration itself (no URIs to fetch), so builds stay +// reproducible and the locked configuration carries the full trust anchor. +type RuntimeKeyringEntry struct { + // Required: the filename the key is written to under /etc/apk/keys. Must + // match the filename the repository's APKINDEX signature references + // (.SIGN.RSA256.<name>), or apk will not find the key at runtime. + Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Required: the PEM-encoded RSA public key content. + Content string `json:"content,omitempty" yaml:"content,omitempty"` +} ++++++ apko.obsinfo ++++++ --- /var/tmp/diff_new_pack.UeLLrt/_old 2026-07-08 17:41:52.368626171 +0200 +++ /var/tmp/diff_new_pack.UeLLrt/_new 2026-07-08 17:41:52.388626868 +0200 @@ -1,5 +1,5 @@ name: apko -version: 1.2.22 -mtime: 1783095113 -commit: f9189c2afb477dd0f7ecab00f3751f0888553c4b +version: 1.2.23 +mtime: 1783433727 +commit: 9d4b52d913063421165620da75b7926bbe211321 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/apko/vendor.tar.gz /work/SRC/openSUSE:Factory/.apko.new.1982/vendor.tar.gz differ: char 15, line 1
