diff --git a/pkg/apk/apk/contents_arch_test.go b/pkg/apk/apk/contents_arch_test.go new file mode 100644 index 000000000..d3bb9c7cd --- /dev/null +++ b/pkg/apk/apk/contents_arch_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 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 apk_test + +import ( + "archive/tar" + "fmt" + "io/fs" + "strings" + "testing" + "time" + + "chainguard.dev/apko/pkg/apk/apk" + "chainguard.dev/apko/pkg/apk/types" + "chainguard.dev/apko/pkg/tarfs" +) + +// stubContents is the minimal PackageContents the arch guard needs: package +// metadata and no files at all. +type stubContents struct { + info *types.PackageInfo +} + +func (s stubContents) PkgInfo() (*types.PackageInfo, error) { return s.info, nil } +func (s stubContents) ControlSection() ([]byte, error) { return []byte("control"), nil } +func (s stubContents) ControlData() ([]byte, error) { return nil, nil } +func (s stubContents) Size() int64 { return 42 } +func (s stubContents) Entries() ([]tar.Header, error) { return nil, nil } +func (s stubContents) FS() fs.FS { return nil } + +// TestInstallPackageContentsArch: one option set serves every architecture of +// a multi-arch build, so contents for the wrong architecture can arrive at any +// context — they must be refused, while matching, noarch, and unstated +// architectures install. +func TestInstallPackageContentsArch(t *testing.T) { + ctx := t.Context() + epoch := time.Time{} + + a, err := apk.New(ctx, apk.WithFS(tarfs.New()), apk.WithArch("aarch64"), apk.WithIgnoreMknodErrors(true)) + if err != nil { + t.Fatal(err) + } + if err := a.InitDB(ctx); err != nil { + t.Fatal(err) + } + + _, err = a.InstallPackageContents(ctx, &epoch, []apk.PackageContents{stubContents{ + info: &types.PackageInfo{Name: "foreign", Version: "1.0.0", Arch: "x86_64"}, + }}) + if err == nil { + t.Fatal("foreign-arch install: got = nil, wanted an error") + } + for _, want := range []string{"x86_64", "aarch64"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("foreign-arch error %q: wanted it to name %q", err, want) + } + } + + for i, arch := range []string{"aarch64", "noarch", ""} { + diffs, err := a.InstallPackageContents(ctx, &epoch, []apk.PackageContents{stubContents{ + info: &types.PackageInfo{Name: fmt.Sprintf("native-%d", i), Version: "1.0.0", Arch: arch}, + }}) + if err != nil { + t.Fatalf("arch %q install: %v", arch, err) + } + if len(diffs) != 1 { + t.Errorf("arch %q diffs: got = %d, wanted = 1", arch, len(diffs)) + } + } +} diff --git a/pkg/apk/apk/implementation.go b/pkg/apk/apk/implementation.go index 2f7779dbd..d7c4ecd0e 100644 --- a/pkg/apk/apk/implementation.go +++ b/pkg/apk/apk/implementation.go @@ -877,6 +877,13 @@ func (a *APK) InstallPackageContents(ctx context.Context, sourceDateEpoch *time. return nil, fmt.Errorf("failed to read .PKGINFO for package %d: %w", i, err) } + // A multi-arch build reuses one option set for every architecture + // context, so contents for the wrong architecture arrive here + // silently; refuse them rather than installing foreign binaries. + if pkgInfo.Arch != "" && pkgInfo.Arch != "noarch" && pkgInfo.Arch != a.arch { + return nil, fmt.Errorf("package %s targets architecture %q, not this context's %q", pkgInfo.Name, pkgInfo.Arch, a.arch) + } + isInstalled, err := a.isInstalledPackage(pkgInfo.Name) if err != nil { return nil, fmt.Errorf("error checking if package %s is installed: %w", pkgInfo.Name, err) diff --git a/pkg/build/build_implementation.go b/pkg/build/build_implementation.go index 6057ff2dc..aa2be6b63 100644 --- a/pkg/build/build_implementation.go +++ b/pkg/build/build_implementation.go @@ -153,7 +153,7 @@ func (bc *Context) buildImage(ctx context.Context) ([]apk.InstalledDiff, error) err error ) switch { - case len(bc.o.PreResolvedPackages) > 0: + case bc.o.PreResolvedPackages != nil: pkgs, err = bc.apk.InstallPackageContents(ctx, &bc.o.SourceDateEpoch, bc.o.PreResolvedPackages) if err != nil { return nil, fmt.Errorf("failed installation from pre-resolved packages: %w", err) diff --git a/pkg/build/options.go b/pkg/build/options.go index 14d56137f..b6743e63a 100644 --- a/pkg/build/options.go +++ b/pkg/build/options.go @@ -266,10 +266,14 @@ func WithLockFile(lockFile string) Option { // WithPreResolvedPackages provides the exact package set to install, in // order, with the contents each member installs from. The build installs // precisely these: no index is consulted and no dependency resolution -// happens. The image configuration's package list still names the requested -// world (written to /etc/apk/world); this option settles how it is satisfied. +// happens — including for an empty set, which installs precisely nothing. +// The image configuration's package list still names the requested world +// (written to /etc/apk/world); this option settles how it is satisfied. func WithPreResolvedPackages(contents []apk.PackageContents) Option { return func(bc *Context) error { + if contents == nil { + contents = []apk.PackageContents{} + } bc.o.PreResolvedPackages = contents return nil } diff --git a/pkg/build/preresolved_test.go b/pkg/build/preresolved_test.go new file mode 100644 index 000000000..8bc84a132 --- /dev/null +++ b/pkg/build/preresolved_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 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 ( + "testing" + + "chainguard.dev/apko/pkg/apk/apk" + "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/tarfs" +) + +// TestEmptyPreResolvedInstallsNothing: an explicitly empty pre-resolved set +// promises a zero-package install with no index consultation — it must not +// fall through to resolution. The configuration's only repository is a local +// path that cannot satisfy anything, so the control build proves resolution +// would fail, and the empty-set build succeeding proves it never resolved. +func TestEmptyPreResolvedInstallsNothing(t *testing.T) { + ctx := t.Context() + ic := types.ImageConfiguration{ + Contents: types.ImageContents{ + BuildRepositories: []string{"/nonexistent/pre-resolved-empty"}, + Packages: []string{"busybox"}, + }, + } + + // Control: without the option, satisfying the world requires resolving + // against the unreachable repository. + control, err := New(ctx, tarfs.New(), WithImageConfiguration(ic), WithArch(types.ParseArchitecture("arm64"))) + if err != nil { + t.Fatal(err) + } + if err := control.BuildImage(ctx); err == nil { + t.Fatal("build without pre-resolved set: got = nil, wanted a resolution error") + } + + // With an explicitly empty set — spelled nil or empty, both mean "this + // set, which is empty" — the build installs precisely nothing and never + // consults the repository. + for _, contents := range [][]apk.PackageContents{nil, {}} { + bc, err := New(ctx, tarfs.New(), + WithImageConfiguration(ic), + WithArch(types.ParseArchitecture("arm64")), + WithPreResolvedPackages(contents), + ) + if err != nil { + t.Fatal(err) + } + if err := bc.BuildImage(ctx); err != nil { + t.Fatalf("empty pre-resolved build: %v", err) + } + installed, err := bc.APK().GetInstalled() + if err != nil { + t.Fatal(err) + } + if len(installed) != 0 { + t.Errorf("installed packages: got = %d, wanted = 0", len(installed)) + } + } +} diff --git a/pkg/options/options.go b/pkg/options/options.go index d9e000371..3c039f5da 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -80,13 +80,16 @@ type Options struct { Offline bool `json:"offline,omitempty"` SharedCache *apk.Cache `json:"-"` Lockfile string `json:"lockfile,omitempty"` - PreResolvedPackages []apk.PackageContents `json:"-"` - Auth auth.Authenticator `json:"-"` - IncludePaths []string `json:"includePaths,omitempty"` - IgnoreSignatures bool `json:"ignoreSignatures,omitempty"` - Transport http.RoundTripper `json:"-"` - PackageGetter apk.PackageGetter `json:"-"` - SizeLimits SizeLimits `json:"sizeLimits,omitempty"` + // PreResolvedPackages, when non-nil, is the exact package set to + // install — possibly empty, which installs nothing; nil means the + // option is unset and the package set is settled another way. + PreResolvedPackages []apk.PackageContents `json:"-"` + Auth auth.Authenticator `json:"-"` + IncludePaths []string `json:"includePaths,omitempty"` + IgnoreSignatures bool `json:"ignoreSignatures,omitempty"` + Transport http.RoundTripper `json:"-"` + PackageGetter apk.PackageGetter `json:"-"` + SizeLimits SizeLimits `json:"sizeLimits,omitempty"` } type Auth struct{ User, Pass string }