diff --git a/.github/workflows/go-oci-build.yml b/.github/workflows/go-oci-build.yml index e518bdc..6d1e2ca 100644 --- a/.github/workflows/go-oci-build.yml +++ b/.github/workflows/go-oci-build.yml @@ -171,14 +171,12 @@ jobs: output="$(jq -er '.result.output' <<<"${envelope}")" version="$(jq -er '.result.version' <<<"${envelope}")" result_build_date="$(jq -er '.result.build_date' <<<"${envelope}")" - binary_name="$(jq -er '.result.binary' <<<"${envelope}")" { printf 'work=%s\n' "${work}" printf 'output=%s\n' "${output}" printf 'version=%s\n' "${version}" printf 'build-date=%s\n' "${result_build_date}" - printf 'binary-name=%s\n' "${binary_name}" } >>"${GITHUB_OUTPUT}" - name: Verify authoritative OCI image @@ -187,13 +185,12 @@ jobs: RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} RELEASE_OUTPUT: ${{ steps.build.outputs.output }} RELEASE_WORK: ${{ steps.build.outputs.work }} - RELEASE_BINARY: ${{ steps.build.outputs.binary-name }} RELEASE_VERSION: ${{ steps.build.outputs.version }} shell: bash run: | set -euo pipefail # The CLI reads the layout byte for byte: the index, every platform - # manifest and config, the staged binary inside the layer blob, and + # manifest and config, every staged binary inside the layer blob, and # the SPDX documents. Revision and source provenance come from the # Actions environment, so they are not passed as flags. The CLI also # writes image-digest.txt into the output root for the publisher. diff --git a/README.md b/README.md index 1231cf1..4505f24 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # Release workflows This repository publishes `release-cli` and reusable GitHub Actions workflows -for releasing one static Go application from one repository. A release can -produce GitHub Release assets, a multi-architecture image in GHCR, Homebrew and -Scoop update pull requests, and signed DEB, RPM, and APK repositories in -Cloudflare R2. +for releasing static Go binaries from one repository. A release can produce +GitHub Release assets, a multi-architecture image in GHCR, Homebrew and Scoop +update pull requests, and signed DEB, RPM, and APK repositories in Cloudflare +R2. ## Supported release The supported application contract is intentionally narrow: -- one Go application and binary per repository; +- one Go repository, one unscoped tag stream, and one GHCR image; +- Linux `amd64` and `arm64` must publish the same nonempty set of static + binary names; - stable, unscoped `vMAJOR.MINOR.PATCH` tags; - static Darwin, Linux, and Windows binaries for `amd64` and `arm64`; - Linux `amd64` and `arm64` images at `ghcr.io//`; diff --git a/docs/explanation/architecture-and-trust.md b/docs/explanation/architecture-and-trust.md index 50faa36..b77f6a3 100644 --- a/docs/explanation/architecture-and-trust.md +++ b/docs/explanation/architecture-and-trust.md @@ -188,7 +188,7 @@ onboarding is a reviewed policy and public-key change rather than a grant of shared production credentials. A replay can converge from current object state, and an immutable conflict fails instead of silently replacing history. -## One application per repository is a deliberate limit +## One repository, one image, one unscoped tag stream The repository name determines the GHCR image name, the caller has one stable unscoped tag stream, the OCI layout has one entrypoint, and Release Please owns @@ -196,10 +196,17 @@ one root manifest version. Supporting multiple applications would require component-aware tags, separate asset namespaces, multiple image names, and more complex ownership and recovery rules across every publisher. -Keeping one application per repository avoids that cross-product. The tradeoff -is more repositories and repeated organization setup. The repeated unit is -operationally visible: each application has one App installation entry, one -release-unit SHA, one draft, one image, and one set of optional destinations. +One repository may stage several GoReleaser binaries into that single image +when Linux `amd64` and `arm64` publish the same nonempty name set. The image +entrypoint remains `/usr/bin/` for one of those names. Multiple +applications, image names, or unscoped tag streams still require separate +repositories. + +Keeping one application identity per repository avoids that cross-product. The +tradeoff is more repositories and repeated organization setup. The repeated +unit is operationally visible: each application has one App installation +entry, one release-unit SHA, one draft, one image, and one set of optional +destinations. ## Unsupported cases preserve these boundaries diff --git a/docs/how-to/adopt-the-release-workflows.md b/docs/how-to/adopt-the-release-workflows.md index e611434..e583fba 100644 --- a/docs/how-to/adopt-the-release-workflows.md +++ b/docs/how-to/adopt-the-release-workflows.md @@ -4,8 +4,15 @@ Use this guide to add the reusable release unit to an existing Go application repository. Complete [Prepare your GitHub organization](prepare-your-github-organization.md) first. -The supported unit releases one application and one binary from one repository. -Use a separate repository and caller for each additional application. +The unit stages every Linux `amd64` and `arm64` GoReleaser binary from one +repository into one GitHub Release and one multi-architecture image. The binary +name set must be identical and nonempty on both architectures. A name present +on only one architecture is an error. Duplicate `(arch, name)` pairs are +rejected. Each platform config Entrypoint must be exactly `/usr/bin/` +for one of those staged names. + +Use a separate repository and caller for another application, image name, or +unscoped tag stream. ## Select one immutable release unit @@ -71,7 +78,9 @@ mixed revision is not a supported migration state. Edit `.goreleaser.yaml` for the producer's command: -- set `project_name`, build ID, archive ID, binary name, and `main` package; +- set `project_name`, each build ID, archive ID, GoReleaser `binary` name, and + `main` package. Linux `amd64` and `arm64` must publish the same set of + binary names; - keep `CGO_ENABLED=0` only if the command is genuinely static on all supported targets; - keep Darwin, Linux, and Windows on `amd64` and `arm64`; @@ -145,23 +154,37 @@ an undeclared replacement when the lock is incomplete. ## Adapt Melange and apko +Staging writes each canonical Linux binary to +`work/sources//`, where `` is the +GoReleaser `builds[].binary` value. The previous staged filename was +`application`. Melange must install each file by its real name. + In `melange.yaml`: -- use the application binary as the package name; +- set the Melange package name; - keep `version: ${{vars.version}}`; - keep `x86_64` and `aarch64`; - replace the organization metadata and SPDX license expression; and -- install the staged `application` file at `/usr/bin/` with mode `0755` - and ownership `0:0`. +- install each staged file by its GoReleaser binary name at + `/usr/bin/` with mode `0755` and ownership `0:0`. + +A single-binary repository is the same contract with one name. Change any +pipeline that still copies `application` to the real binary name before the +next release-unit pin. In `apko.yaml`: - consume the same Melange package; -- set the entrypoint to `/usr/bin/`; +- set the entrypoint to `/usr/bin/` for one staged binary name; - keep `amd64` and `arm64`; - keep numeric runtime user and group `65532`; and - set title, description, source, and SPDX license annotations. +Each platform config Entrypoint must be exactly `["/usr/bin/"]` for +some expected staged name. The same name is required on every platform. The +image has one entrypoint; additional staged binaries are present at +`/usr/bin/` and are not extra entrypoints. + The current example includes CA certificates. Keep them for a command that makes TLS connections. Add other runtime files through apko packages rather than copying the build environment into the image. @@ -247,6 +270,7 @@ After the first image publication, confirm GHCR visibility as described in the organization guide. Consumers that require repeatability must use the `ghcr.io//@sha256:` output, not a moving channel tag. -To release another application, repeat this guide in another repository. Do -not add a second command, component-prefixed tag, or second image name to the -same caller. +To release another application or image, repeat this guide in another +repository. Do not add a component-prefixed tag or second image name to the +same caller. Additional GoReleaser binaries in the same repository are staged +into the same image when both architectures publish the same name set. diff --git a/docs/reference/release-cli.md b/docs/reference/release-cli.md index f818bf1..9c04b46 100644 --- a/docs/reference/release-cli.md +++ b/docs/reference/release-cli.md @@ -92,9 +92,12 @@ goreleaser release --clean --skip=publish ``` The command then verifies every `checksums.txt` entry, requires a regular -`checksums.txt.sigstore.json`, reads `artifacts.json`, requires one static -executable Linux binary for `amd64` and one for `arm64`, confines both paths, -and writes `oci-build-inputs.json`. +`checksums.txt.sigstore.json`, reads `artifacts.json`, and selects every +`linux/{amd64,arm64}` Binary record. Duplicate `(arch, name)` pairs are +rejected. The name set must be identical and nonempty on both architectures; a +name present on only one architecture is an error that names it. Selected +paths are confined, and the command writes `oci-build-inputs.json` as +`release.dev/oci-build-inputs/v2`. Native package signing is controlled by environment only: @@ -116,8 +119,7 @@ JSON result: | Field | Contract | | --- | --- | | `assets` | Number of checksum-verified payloads. | -| `binaries.amd64.path`, `binaries.arm64.path` | Original dist-prefixed paths from `artifacts.json`. | -| `binaries..mode` | Observed permission bits in octal. | +| `binaries` | Selected Linux binaries in platform-major order (`amd64` then `arm64`), then name ascending. Each entry has `arch`, `name`, original dist-prefixed `path`, and observed permission `mode` in octal. | `--clean` deletes and rebuilds the distribution directory. This command is not read-only. @@ -146,23 +148,24 @@ Required Actions context is `GITHUB_REPOSITORY`, `GITHUB_REPOSITORY_OWNER`, `GITHUB_SERVER_URL`, and `GITHUB_SHA`. Work and output roots must be disjoint, absent or empty, and neither may contain the other. -The command verifies the projected binary digests and ELF contract, stages each -as `application`, creates an ephemeral Melange signing key, builds `x86_64` and -`aarch64` APK repositories, writes the public key, locks apko, and composes the -OCI layout and architecture SBOMs. The private Melange key remains under the -scratch root. +The command verifies the projected binary digests and ELF contract, stages +each file at `work/sources//`, creates an ephemeral +Melange signing key, builds `x86_64` and `aarch64` APK repositories, writes +the public key, locks apko, and composes the OCI layout and architecture +SBOMs. The private Melange key remains under the scratch root. `oci-build-inputs.json` is limited to 4 MiB. -The `release.dev/image-build/v1` JSON result contains `version`, `binary`, -`work`, `output`, `build_date`, and two `packages` entries. Each package entry -contains `platform`, APK `arch`, output-relative `package`, and -`binary_digest`. +The `release.dev/image-build/v2` JSON result contains `version`, `binaries` +(sorted name-ascending), `work`, `output`, `build_date`, and two `packages` +entries. Each package entry contains `platform`, APK `arch`, output-relative +`package`, and `binary_digests`. Each `binary_digests` entry is +`{name, digest}` sorted by name. ## `image verify` ```text -release-cli image verify --output DIR --work DIR --binary NAME \ +release-cli image verify --output DIR --work DIR \ [--version VERSION] [--json] ``` @@ -170,28 +173,30 @@ release-cli image verify --output DIR --work DIR --binary NAME \ | --- | --- | --- | --- | | Output root | `--output` | `RELEASE_OUTPUT` | Required. | | Scratch root | `--work` | `RELEASE_WORK` | Required. | -| Binary | `--binary` | `RELEASE_BINARY` | Required. | | Version | `--version` | `RELEASE_VERSION` | Tag name without one leading `v`. | The command also requires `GITHUB_SHA`, `GITHUB_SERVER_URL`, and -`GITHUB_REPOSITORY`. It verifies: +`GITHUB_REPOSITORY`. Expected binary names and canonical digests come from +the staged `work/sources//` trees and the v2 +projection. It verifies: - one OCI index with Linux `amd64` and `arm64` manifests; - required source, version, revision, title, description, and license annotations on index, manifests, and config labels; -- one layer, entrypoint `/usr/bin/`, and runtime user `65532`; -- one regular binary with ownership `0:0`, mode `0755`, no special bits, and - bytes equal to the staged canonical binary; and +- one layer, runtime user `65532`, and an Entrypoint of exactly + `/usr/bin/` for one expected staged name on every platform; +- every expected name present exactly once in that layer as a regular `0755` + uid/gid `0` file within 64 MiB, with bytes equal to its canonical digest; + and - one SPDX `APPLICATION` package at `-r0` per architecture. -Index, manifest, config, and SPDX JSON documents are limited to 4 MiB. The -binary entry in each layer is limited to 64 MiB. +Index, manifest, config, and SPDX JSON documents are limited to 4 MiB. The index digest is SHA-256 of the exact `layout/index.json` bytes. On success, the command writes it to `image-digest.txt` and returns a -`release.dev/image-verify/v1` result containing `version`, `binary`, -`index_digest`, and two platform records with manifest, config, layer, and -binary digests. +`release.dev/image-verify/v2` result containing `version`, `binaries` (sorted +name-ascending), `index_digest`, and two platform records with manifest, +config, layer, and `binary_digests` (`{name, digest}` sorted by name). ## `plan tags` diff --git a/docs/reference/release-system.md b/docs/reference/release-system.md index 276bb15..9f862fc 100644 --- a/docs/reference/release-system.md +++ b/docs/reference/release-system.md @@ -9,7 +9,7 @@ failure handling, see [Operate and recover releases](../how-to/operate-and-recov | Domain | Supported contract | | --- | --- | -| Application layout | One Go application and binary per repository. | +| Application layout | One Go repository, one unscoped tag stream, and one GHCR image. Linux `amd64` and `arm64` must publish the same nonempty set of static binary names. | | Source tags | Stable, unscoped `vMAJOR.MINOR.PATCH`. | | Binary operating systems | Darwin, Linux, and Windows. | | Binary architectures | `amd64` and `arm64`. | @@ -328,7 +328,7 @@ one convergent repository publication. The producer supplies: -- one Go module and command; +- one Go module and one or more commands that share that module; - `.goreleaser.yaml` schema version 2; - `mise.toml` and `mise.lock` with Go, GoReleaser, Syft, Cosign, GitHub CLI, Melange, and apko; @@ -353,14 +353,20 @@ A compatible GoReleaser configuration: - uses `skip_upload: true` for Homebrew and Scoop controls; and - keeps nFPM ID `release` when optional native signing is enabled. -Staging requires exactly one executable static Linux binary for `amd64` and one -for `arm64`, both with the same filename. It writes the digest-bound -`oci-build-inputs.json` projection. +Staging selects every `linux/{amd64,arm64}` GoReleaser Binary record. It +rejects a duplicate `(arch, name)` pair and requires the name set to be +identical and nonempty on both architectures. A name present on only one +architecture is an error that names it. The `release.dev/oci-build-inputs/v2` +projection lists those binaries in platform-major order (`linux/amd64` before +`linux/arm64`), then name ascending within a platform. Melange packages the projected files for `x86_64` and `aarch64` without -compiling them. apko composes one index for `amd64` and `arm64`, runs as numeric -user and group `65532`, installs one `/usr/bin/` entrypoint, and carries -source, version, revision, title, description, and license annotations. +compiling them. Each staged file is named for its GoReleaser binary, not +`application`. apko composes one index for `amd64` and `arm64`, runs as numeric +user and group `65532`, and requires each platform config Entrypoint to be +exactly `/usr/bin/` for one staged binary name. The same name is required +on every platform. Source, version, revision, title, description, and license +annotations remain required. ## Actions artifacts and public assets @@ -617,7 +623,7 @@ immutable R2 conflict. A public release has no automated rollback. The current release system does not support: - languages other than the Go producer profile; -- more than one application, binary, or image entrypoint per repository; +- more than one application, GHCR image, or image entrypoint per repository; - monorepo component tags or scoped versions; - prereleases or build metadata; - CGO-dependent or dynamically linked commands; diff --git a/docs/tutorials/release-your-first-go-application.md b/docs/tutorials/release-your-first-go-application.md index a31989d..57c1fe7 100644 --- a/docs/tutorials/release-your-first-go-application.md +++ b/docs/tutorials/release-your-first-go-application.md @@ -89,9 +89,10 @@ Edit the copied files as follows: and release URL owner and repository to `acme/widget`; and replace the example organization metadata. 4. In `release-please-config.json`, set `package-name` to `widget`. -5. In `melange.yaml`, set the package and installed binary to `widget`, and - replace the example organization metadata. -6. In `apko.yaml`, set the package and entrypoint to `widget` and use +5. In `melange.yaml`, set the package name to `widget`, install the staged + `widget` file at `/usr/bin/widget`, and replace the example organization + metadata. +6. In `apko.yaml`, set the package and entrypoint to `/usr/bin/widget` and use `https://github.com/acme/widget` as the source annotation. Keep these release controls unchanged: diff --git a/examples/go-release/README.md b/examples/go-release/README.md index 9a654ef..2a32a14 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -1,9 +1,10 @@ # Go release example -This directory is the maintained template for one static Go application in one +This directory is the maintained template for one static Go command in one repository. It includes the release workflows, a minimal command, GitHub Release and OCI configuration, Homebrew and Scoop control generation, and a -native package-repository request. +native package-repository request. Melange installs the staged `example` +binary by that GoReleaser name; the image entrypoint is `/usr/bin/example`. It is not a complete repository policy. Add the adopter's CI, review, rulesets, and ownership controls. @@ -39,7 +40,8 @@ Before the workflows run: `SCOOP-BUCKET`, and the `PACKAGE-REPOSITORY-*` values only after those adopter-owned destinations exist. 4. Replace the `example` project, package, binary, cask, manifest, command path, - module path, and Release Please package name. + module path, and Release Please package name. `melange.yaml` must install + the staged file by that GoReleaser binary name, not `application`. 5. Replace the organization metadata, maintainer, description, homepage, and SPDX license expression. 6. Change the Release Please branch and manifest version when the repository diff --git a/examples/go-release/melange.yaml b/examples/go-release/melange.yaml index ac7fe2e..f90a1b5 100644 --- a/examples/go-release/melange.yaml +++ b/examples/go-release/melange.yaml @@ -26,4 +26,4 @@ environment: pipeline: - runs: | - install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/example" + install -Dm755 -o 0 -g 0 example "${{targets.destdir}}/usr/bin/example" diff --git a/internal/cli/image.go b/internal/cli/image.go index 9789914..82e9d32 100644 --- a/internal/cli/image.go +++ b/internal/cli/image.go @@ -5,7 +5,9 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" + "slices" "strings" "time" @@ -108,7 +110,6 @@ func newImageVerifyCommand(options Options) *cobra.Command { } cmd.Flags().String(flagOutput, "", "path to the authoritative artifact output root") cmd.Flags().String(flagWork, "", "path to the scratch workspace") - cmd.Flags().String(flagBinary, "", "staged binary filename") cmd.Flags().String(flagVersion, "", "stable MAJOR.MINOR.PATCH version") return cmd @@ -162,13 +163,17 @@ func runImageVerify(options Options) error { } arches := imageVerifyArches() - canonical, err := image.CanonicalDigests(work.FS(), arches) + names, err := listStagedBinaryNames(work.FS()) + if err != nil { + return writeCommandResult(options, commandImageVerify, nil, err) + } + canonical, err := image.CanonicalDigests(work.FS(), arches, names) if err != nil { return writeCommandResult(options, commandImageVerify, nil, err) } want := image.ExpectedImage{ Version: expected.Version, - Binary: expected.Binary, + Binaries: names, Revision: expected.Revision, Source: expected.Source, Canonical: canonical, @@ -208,8 +213,6 @@ type imageVerifyConfig struct { Output string // Work is the scratch workspace. Work string - // Binary is the staged binary filename. - Binary string // Version is the candidate stable release version. Version rel.Version // Revision is the expected org.opencontainers.image.revision value. @@ -235,9 +238,6 @@ func resolveImageVerify(options Options) (imageVerifyConfig, error) { if settings.Work == "" { return imageVerifyConfig{}, fmt.Errorf("--%s is required", flagWork) } - if err := validateImageVerifyBinary(settings.Binary); err != nil { - return imageVerifyConfig{}, err - } version, err := resolvePlanVersion(settings, options.LookupEnv) if err != nil { @@ -259,7 +259,6 @@ func resolveImageVerify(options Options) (imageVerifyConfig, error) { return imageVerifyConfig{ Output: settings.Output, Work: settings.Work, - Binary: settings.Binary, Version: version, Revision: revision, Source: strings.TrimRight(serverURL, "/") + "/" + repository, @@ -284,6 +283,51 @@ func validateImageVerifyBinary(name string) error { return nil } +// listStagedBinaryNames reads work/sources/ and requires the same nonempty name set. +func listStagedBinaryNames(work fs.FS) ([]string, error) { + arches := imageVerifyArches() + namesByArch := make(map[image.APKArch]map[string]struct{}, len(arches)) + union := make(map[string]struct{}) + for _, arch := range arches { + dir := path.Join("sources", arch.String()) + entries, err := fs.ReadDir(work, dir) + if err != nil { + return nil, fmt.Errorf("read %s: %w", dir, err) + } + names := make(map[string]struct{}) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if err := validateImageVerifyBinary(name); err != nil { + return nil, fmt.Errorf("%s/%s: %w", dir, name, err) + } + names[name] = struct{}{} + union[name] = struct{}{} + } + namesByArch[arch] = names + } + if len(union) == 0 { + return nil, errors.New("staged binary name list is empty") + } + ordered := make([]string, 0, len(union)) + for name := range union { + ordered = append(ordered, name) + } + slices.Sort(ordered) + for _, arch := range arches { + have := namesByArch[arch] + for _, name := range ordered { + if _, ok := have[name]; !ok { + return nil, fmt.Errorf("missing staged binary %q for %s", name, arch) + } + } + } + + return ordered, nil +} + // imageVerifyArches returns the closed APK architecture set in canonical order. func imageVerifyArches() []image.APKArch { return []image.APKArch{image.ArchX8664, image.ArchAArch64} diff --git a/internal/cli/image_build_test.go b/internal/cli/image_build_test.go index 0c27fee..a06812d 100644 --- a/internal/cli/image_build_test.go +++ b/internal/cli/image_build_test.go @@ -558,22 +558,28 @@ func TestImageBuildJSONSuccess(t *testing.T) { result := decodeImageBuildResult(t, stdout) assert.Equal(t, image.BuildSchema, result.Schema) assert.Equal(t, imageVersion, result.Version) - assert.Equal(t, imageBinaryName, result.Binary) + assert.Equal(t, []string{imageBinaryName}, result.Binaries) assert.Equal(t, work, result.Work) assert.Equal(t, output, result.Output) assert.Equal(t, imageBuildDate, result.BuildDate) assert.Equal(t, []image.PackageResult{ { - Platform: "linux/amd64", - Arch: "x86_64", - Package: "packages/x86_64/release-cli-1.2.3-r0.apk", - BinaryDigest: tree.amd64Digest, + Platform: "linux/amd64", + Arch: "x86_64", + Package: "packages/x86_64/release-cli-1.2.3-r0.apk", + BinaryDigests: []image.BinaryDigest{{ + Name: imageBinaryName, + Digest: tree.amd64Digest, + }}, }, { - Platform: "linux/arm64", - Arch: "aarch64", - Package: "packages/aarch64/release-cli-1.2.3-r0.apk", - BinaryDigest: tree.arm64Digest, + Platform: "linux/arm64", + Arch: "aarch64", + Package: "packages/aarch64/release-cli-1.2.3-r0.apk", + BinaryDigests: []image.BinaryDigest{{ + Name: imageBinaryName, + Digest: tree.arm64Digest, + }}, }, }, result.Packages) } @@ -644,5 +650,5 @@ func TestImageBuildFactoryPaths(t *testing.T) { require.NoError(t, err) assert.Equal(t, imageMelangePath, gotMelange) assert.Equal(t, imageApkoPath, gotApko) - assert.Equal(t, imageBinaryName, decodeImageBuildResult(t, stdout).Binary) + assert.Equal(t, []string{imageBinaryName}, decodeImageBuildResult(t, stdout).Binaries) } diff --git a/internal/cli/image_verify_test.go b/internal/cli/image_verify_test.go index 9a9e1cd..9fb9ff8 100644 --- a/internal/cli/image_verify_test.go +++ b/internal/cli/image_verify_test.go @@ -27,9 +27,9 @@ const ( imageVerifyCommand = "image verify" // imageVerifyDigestFile is the output-relative index digest artifact. imageVerifyDigestFile = "image-digest.txt" - // imageVerifyAMD64Binary is the staged amd64 application fixture. + // imageVerifyAMD64Binary is the staged amd64 binary fixture. imageVerifyAMD64Binary = "amd64-application" - // imageVerifyARM64Binary is the staged arm64 application fixture. + // imageVerifyARM64Binary is the staged arm64 binary fixture. imageVerifyARM64Binary = "arm64-application" // imageVerifyCreated is the index created annotation fixture. imageVerifyCreated = "2024-01-02T03:04:05Z" @@ -45,7 +45,7 @@ const ( imageVerifyLayoutJSON = `{"imageLayoutVersion":"1.0.0"}` // imageVerifyFileMode is the mode of regular fixture files. imageVerifyFileMode = 0o644 - // imageVerifyExecMode is the mode of staged application files and the layer entry. + // imageVerifyExecMode is the mode of staged binary files and the layer entry. imageVerifyExecMode = 0o755 // imageVerifyDirMode is the mode of fixture directories. imageVerifyDirMode = 0o755 @@ -60,7 +60,6 @@ func TestImageVerifyJSONSuccess(t *testing.T) { "--json", "--output", tree.output, "--work", tree.work, - "--binary", imageBinaryName, "--version", imageVersion, }) require.NoError(t, err) @@ -75,24 +74,30 @@ func TestImageVerifyJSONSuccess(t *testing.T) { result := decodeImageVerifyResult(t, stdout) assert.Equal(t, image.VerifySchema, result.Schema) assert.Equal(t, imageVersion, result.Version) - assert.Equal(t, imageBinaryName, result.Binary) + assert.Equal(t, []string{imageBinaryName}, result.Binaries) assert.Equal(t, tree.indexDigest, result.IndexDigest) assert.Equal(t, []image.VerifiedPlatform{ { - Platform: "linux/amd64", - Arch: "x86_64", - Manifest: tree.amd64.manifest, - Config: tree.amd64.config, - Layer: tree.amd64.layer, - BinaryDigest: tree.amd64.binary, + Platform: "linux/amd64", + Arch: "x86_64", + Manifest: tree.amd64.manifest, + Config: tree.amd64.config, + Layer: tree.amd64.layer, + BinaryDigests: []image.BinaryDigest{{ + Name: imageBinaryName, + Digest: tree.amd64.binary, + }}, }, { - Platform: "linux/arm64", - Arch: "aarch64", - Manifest: tree.arm64.manifest, - Config: tree.arm64.config, - Layer: tree.arm64.layer, - BinaryDigest: tree.arm64.binary, + Platform: "linux/arm64", + Arch: "aarch64", + Manifest: tree.arm64.manifest, + Config: tree.arm64.config, + Layer: tree.arm64.layer, + BinaryDigests: []image.BinaryDigest{{ + Name: imageBinaryName, + Digest: tree.arm64.binary, + }}, }, }, result.Platforms) assert.Equal(t, tree.indexDigest+"\n", readImageDigestFile(t, tree.output)) @@ -106,7 +111,6 @@ func TestImageVerifySilentSuccess(t *testing.T) { "image", "verify", "--output", tree.output, "--work", tree.work, - "--binary", imageBinaryName, "--version", imageVersion, }) require.NoError(t, err) @@ -131,7 +135,6 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { return []string{ "image", "verify", "--work", work, - "--binary", imageBinaryName, "--version", imageVersion, } }, @@ -144,25 +147,11 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { return []string{ "image", "verify", "--output", output, - "--binary", imageBinaryName, "--version", imageVersion, } }, want: "--work is required", }, - { - name: "missing binary", - env: imageVerifyEnv(), - args: func(output, work string) []string { - return []string{ - "image", "verify", - "--output", output, - "--work", work, - "--version", imageVersion, - } - }, - want: "binary name is empty", - }, { name: "unresolvable version", env: imageVerifyEnv(), @@ -171,7 +160,6 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { "image", "verify", "--output", output, "--work", work, - "--binary", imageBinaryName, } }, want: "--version is required when GITHUB_REF_NAME is unset", @@ -184,7 +172,6 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { "image", "verify", "--output", output, "--work", work, - "--binary", imageBinaryName, "--version", imageVersion, } }, @@ -198,7 +185,6 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { "image", "verify", "--output", output, "--work", work, - "--binary", imageBinaryName, "--version", imageVersion, } }, @@ -212,7 +198,6 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { "image", "verify", "--output", output, "--work", work, - "--binary", imageBinaryName, "--version", imageVersion, } }, @@ -236,54 +221,6 @@ func TestImageVerifyMissingValuesAreUsage(t *testing.T) { } } -func TestImageVerifyInvalidBinaryIsUsage(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - binary string - want string - }{ - { - name: "empty binary", - binary: "", - want: "binary name is empty", - }, - { - name: "path separator", - binary: "usr/bin/tool", - want: `binary name "usr/bin/tool" contains a path separator`, - }, - { - name: "dot-dot", - binary: "..", - want: `binary name ".." is not a filename`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - output := t.TempDir() - work := t.TempDir() - stdout, err := executeImageVerifyStdout(t, imageVerifyEnv(), []string{ - "image", "verify", - "--json", - "--output", output, - "--work", work, - "--binary", tt.binary, - "--version", imageVersion, - }) - require.Error(t, err) - assert.Equal(t, 2, cli.ExitCode(err)) - assert.Contains(t, err.Error(), tt.want) - assertImageVerifyFailureEnvelope(t, stdout, tt.want) - assertPathAbsent(t, filepath.Join(output, imageVerifyDigestFile)) - }) - } -} - func TestImageVerifyLayoutFailure(t *testing.T) { t.Parallel() @@ -296,7 +233,6 @@ func TestImageVerifyLayoutFailure(t *testing.T) { "--json", "--output", tree.output, "--work", tree.work, - "--binary", imageBinaryName, "--version", imageVersion, }) require.Error(t, err) @@ -316,7 +252,6 @@ func TestImageVerifySBOMFailure(t *testing.T) { "--json", "--output", tree.output, "--work", tree.work, - "--binary", imageBinaryName, "--version", imageVersion, }) require.Error(t, err) @@ -349,7 +284,6 @@ func TestImageVerifyMissingLayoutOrSBOMs(t *testing.T) { "--json", "--output", tree.output, "--work", tree.work, - "--binary", imageBinaryName, "--version", imageVersion, }) require.Error(t, err) @@ -382,7 +316,7 @@ type imageVerifyPlatform struct { config string // layer is the single layer digest. layer string - // binary is the staged application digest. + // binary is the staged binary digest. binary string } @@ -506,11 +440,11 @@ func writeImageVerifySBOMs(t *testing.T, output string) { writeFile(t, filepath.Join(output, "sboms", "sbom-index.spdx.json"), `{"packages":[]}`) } -// writeImageVerifyApplication writes work/sources//application. +// writeImageVerifyApplication writes work/sources//. func writeImageVerifyApplication(t *testing.T, work, arch string, binary []byte) { t.Helper() - path := filepath.Join(work, "sources", arch, "application") + path := filepath.Join(work, "sources", arch, imageBinaryName) require.NoError(t, os.MkdirAll(filepath.Dir(path), imageVerifyDirMode)) require.NoError(t, os.WriteFile(path, binary, imageVerifyExecMode)) } diff --git a/internal/cli/result.go b/internal/cli/result.go index fdf6dfd..1d00156 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -78,6 +78,10 @@ type ScoopBucketInitResult struct { // BinaryResult describes one verified canonical binary. type BinaryResult struct { + // Arch is the GOARCH of the selected binary. + Arch string `json:"arch"` + // Name is the binary filename. + Name string `json:"name"` // Path is the original GoReleaser path, including the --dist basename prefix. Path string `json:"path"` // Mode is the observed permission bits as an octal string. @@ -88,8 +92,8 @@ type BinaryResult struct { type StageResult struct { // Assets is the number of checksummed payloads that matched. Assets int `json:"assets"` - // Binaries maps GOARCH onto the verified binary path and mode. - Binaries map[string]BinaryResult `json:"binaries"` + // Binaries are the verified Linux binaries, architecture-major then name-ascending. + Binaries []BinaryResult `json:"binaries"` } // ArtifactHandoffResult is one verified Actions artifact. diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 819c0b5..1d22d20 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -230,8 +230,8 @@ func TestStageMissingArchitectureRecord(t *testing.T) { ) require.Error(t, err) assert.Equal(t, 1, cli.ExitCode(err)) - assert.Contains(t, err.Error(), "missing linux Binary record for arm64") - assertFailureEnvelope(t, stdout, "missing linux Binary record for arm64") + assert.Contains(t, err.Error(), "missing linux/arm64 Binary record for app") + assertFailureEnvelope(t, stdout, "missing linux/arm64 Binary record for app") } func TestStageEscapedPath(t *testing.T) { diff --git a/internal/cli/stage.go b/internal/cli/stage.go index 08d65ce..483912b 100644 --- a/internal/cli/stage.go +++ b/internal/cli/stage.go @@ -141,13 +141,15 @@ func runStage(ctx context.Context, options Options) error { result := StageResult{ Assets: report.Assets, - Binaries: make(map[string]BinaryResult, len(report.Binaries)), + Binaries: make([]BinaryResult, 0, len(report.Binaries)), } for _, binary := range report.Binaries { - result.Binaries[binary.Arch] = BinaryResult{ + result.Binaries = append(result.Binaries, BinaryResult{ + Arch: binary.Arch, + Name: binary.Name, Path: binary.Path, Mode: strconv.FormatUint(uint64(binary.Mode), octalBase), - } + }) } return writeCommandResult(options, "stage", result, nil) diff --git a/internal/cli/stage_test.go b/internal/cli/stage_test.go index fb71c17..1792c47 100644 --- a/internal/cli/stage_test.go +++ b/internal/cli/stage_test.go @@ -57,7 +57,7 @@ func TestStageJSONStillWritesImageInputs(t *testing.T) { assert.Equal(t, 1, countJSONDocuments(stdout)) assert.Contains(t, stdout, `"command":"stage"`) assert.Contains(t, stdout, `"ok":true`) - assert.NotContains(t, stdout, `"schema":"release.dev/oci-build-inputs/v1"`) + assert.NotContains(t, stdout, `"schema":"release.dev/oci-build-inputs/v2"`) payload, err := os.ReadFile(filepath.Join(dist, stage.ImageInputsName)) require.NoError(t, err) @@ -446,7 +446,7 @@ func expectedImageInputs() stage.ImageInputs { // expectedImageInputsJSON is the compact projection document written for [goodDist]. func expectedImageInputsJSON() string { - return `{"schema":"release.dev/oci-build-inputs/v1","profile":"go","binaries":[` + + return `{"schema":"release.dev/oci-build-inputs/v2","profile":"go","binaries":[` + `{"platform":"linux/amd64","name":"app","path":"app_linux_amd64/app","digest":"sha256:` + sha256Hex("amd64") + `"},` + `{"platform":"linux/arm64","name":"app","path":"app_linux_arm64/app","digest":"sha256:` + sha256Hex("arm64") + `"}` + "]}\n" diff --git a/internal/profile/goprof/artifacts.go b/internal/profile/goprof/artifacts.go index 3cc8954..ebbaa49 100644 --- a/internal/profile/goprof/artifacts.go +++ b/internal/profile/goprof/artifacts.go @@ -77,7 +77,7 @@ type CanonicalBinary struct { Path ArtifactPath // RelativePath is Path with the leading root name stripped for [io/fs.FS] lookup. RelativePath RelativePath - // Name is the binary filename, identical across selected platforms. + // Name is the binary filename from this record. Name BinaryName } @@ -170,7 +170,7 @@ func (n BinaryName) String() string { } // requiredArchs returns the closed set of Linux GOARCH values that must -// each appear exactly once as a Binary record. +// each appear with the same nonempty binary name set. func requiredArchs() []string { return []string{"amd64", "arm64"} } @@ -198,62 +198,50 @@ func parseArtifacts(r io.Reader) ([]Record, error) { return records, nil } -// SelectBinaries returns exactly one linux Binary per required architecture. +// SelectBinaries returns every linux Binary for the required architectures. // -// Zero, duplicate, or missing architectures fail with a diagnostic that names -// the architectures that were found. Paths must be relative to root, the -// basename of the --dist directory (GoReleaser writes "/..."). After -// selection succeeds, every binary must carry the same [BinaryName]. +// Each linux/{amd64,arm64} Binary is selected. A duplicate (architecture, name) +// pair fails with a diagnostic that names the pair. Paths must be relative to +// root, the basename of the --dist directory (GoReleaser writes "/..."). +// The name set must be nonempty and identical across both architectures; a +// name present on only one architecture is an error that names it. The result +// is sorted architecture-major (amd64, then arm64), then name-ascending. func SelectBinaries(records []Record, root RootName) ([]CanonicalBinary, error) { if root == "" { return nil, errors.New("dist root name is empty") } - required := requiredArchs() - selected := make(map[Arch]CanonicalBinary, len(required)) - rawNames := make(map[Arch]string, len(required)) + selected := make(map[archName]CanonicalBinary) var found []string for _, record := range records { if record.Type != binaryType || record.GOOS != linuxOS { continue } - next, err := selectLinuxBinary(record, root, selected, rawNames, found) + next, err := selectLinuxBinary(record, root, selected, found) if err != nil { return nil, err } found = next } - var missing []string - out := make([]CanonicalBinary, 0, len(required)) - for _, name := range required { - arch := Arch(name) - binary, ok := selected[arch] - if !ok { - missing = append(missing, name) - continue - } - out = append(out, binary) - } - if len(missing) > 0 { - return nil, fmt.Errorf( - "missing linux Binary record for %s; found %s", - joinArchs(missing), - joinArchs(found), - ) - } + return assembleBinaries(selected, found) +} - return assignSharedNames(out, rawNames) +// archName is a selected architecture and binary filename pair. +type archName struct { + // arch is the Linux GOARCH. + arch Arch + // name is the binary filename. + name BinaryName } -// selectLinuxBinary records one linux Binary for its architecture. +// selectLinuxBinary records one linux Binary for its (architecture, name) pair. // // found is the architectures seen so far, including this record's GOARCH. func selectLinuxBinary( record Record, root RootName, - selected map[Arch]CanonicalBinary, - rawNames map[Arch]string, + selected map[archName]CanonicalBinary, found []string, ) ([]string, error) { found = append(found, record.GOARCH) @@ -265,10 +253,16 @@ func selectLinuxBinary( joinArchs(found), ) } - if _, exists := selected[arch]; exists { + name, err := ParseBinaryName(record.Name) + if err != nil { + return found, err + } + key := archName{arch: arch, name: name} + if _, exists := selected[key]; exists { return found, fmt.Errorf( - "duplicate linux/%s Binary record; found %s", + "duplicate linux/%s Binary record for %q; found %s", record.GOARCH, + name, joinArchs(found), ) } @@ -280,36 +274,75 @@ func selectLinuxBinary( if err != nil { return found, err } - selected[arch] = CanonicalBinary{ + selected[key] = CanonicalBinary{ Arch: arch, Path: artifactPath, RelativePath: relative, + Name: name, } - rawNames[arch] = record.Name return found, nil } -// assignSharedNames requires every selected binary to share one filename. -func assignSharedNames(binaries []CanonicalBinary, rawNames map[Arch]string) ([]CanonicalBinary, error) { - var shared BinaryName - for i, binary := range binaries { - parsed, err := ParseBinaryName(rawNames[binary.Arch]) - if err != nil { - return nil, err +// assembleBinaries requires a nonempty identical name set on both architectures. +func assembleBinaries(selected map[archName]CanonicalBinary, found []string) ([]CanonicalBinary, error) { + required := requiredArchs() + namesByArch := make(map[Arch]map[BinaryName]struct{}, len(required)) + for _, raw := range required { + namesByArch[Arch(raw)] = make(map[BinaryName]struct{}) + } + for key := range selected { + namesByArch[key.arch][key.name] = struct{}{} + } + + union := make(map[BinaryName]struct{}) + for _, names := range namesByArch { + for name := range names { + union[name] = struct{}{} } - if i > 0 && parsed != shared { + } + if len(union) == 0 { + return nil, fmt.Errorf( + "missing linux Binary record for %s; found %s", + joinArchs(required), + joinArchs(found), + ) + } + + var names []string + for name := range union { + names = append(names, name.String()) + } + slices.Sort(names) + + for _, raw := range required { + arch := Arch(raw) + have := namesByArch[arch] + var missing []string + for _, name := range names { + if _, ok := have[BinaryName(name)]; !ok { + missing = append(missing, name) + } + } + if len(missing) > 0 { return nil, fmt.Errorf( - "linux architecture binaries have different names %q and %q", - shared, - parsed, + "missing linux/%s Binary record for %s; found %s", + arch, + joinArchs(missing), + joinArchs(found), ) } - shared = parsed - binaries[i].Name = parsed } - return binaries, nil + out := make([]CanonicalBinary, 0, len(selected)) + for _, raw := range required { + arch := Arch(raw) + for _, name := range names { + out = append(out, selected[archName{arch: arch, name: BinaryName(name)}]) + } + } + + return out, nil } // VerifyBinaries checks each selected path against fsys. diff --git a/internal/profile/goprof/artifacts_test.go b/internal/profile/goprof/artifacts_test.go index 6901467..b813ef9 100644 --- a/internal/profile/goprof/artifacts_test.go +++ b/internal/profile/goprof/artifacts_test.go @@ -114,8 +114,18 @@ func TestSelectBinaries(t *testing.T) { }, }, want: []goprof.CanonicalBinary{ - canonical("amd64", "dist/release-cli_linux_amd64/release-cli", "release-cli_linux_amd64/release-cli"), - canonical("arm64", "dist/release-cli_linux_arm64/release-cli", "release-cli_linux_arm64/release-cli"), + canonical( + "amd64", + "dist/release-cli_linux_amd64/release-cli", + "release-cli_linux_amd64/release-cli", + "release-cli", + ), + canonical( + "arm64", + "dist/release-cli_linux_arm64/release-cli", + "release-cli_linux_arm64/release-cli", + "release-cli", + ), }, }, { @@ -126,8 +136,8 @@ func TestSelectBinaries(t *testing.T) { linuxBinary("arm64", "build/app_linux_arm64/app"), }, want: []goprof.CanonicalBinary{ - canonical("amd64", "build/app_linux_amd64/app", "app_linux_amd64/app"), - canonical("arm64", "build/app_linux_arm64/app", "app_linux_arm64/app"), + canonical("amd64", "build/app_linux_amd64/app", "app_linux_amd64/app", "release-cli"), + canonical("arm64", "build/app_linux_arm64/app", "app_linux_arm64/app", "release-cli"), }, }, { @@ -137,7 +147,7 @@ func TestSelectBinaries(t *testing.T) { {Type: "Archive", GOOS: "linux", GOARCH: "amd64", Path: "dist/a.tar.gz", Name: "a.tar.gz"}, linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), }, - wantErr: "missing linux Binary record for amd64; found arm64", + wantErr: "missing linux/amd64 Binary record for release-cli; found arm64", }, { name: "wrong goos is ignored then missing architecture fails", @@ -152,17 +162,17 @@ func TestSelectBinaries(t *testing.T) { }, linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), }, - wantErr: "missing linux Binary record for amd64; found arm64", + wantErr: "missing linux/amd64 Binary record for release-cli; found arm64", }, { - name: "duplicate architecture", + name: "duplicate architecture and name", rootName: "dist", records: []goprof.Record{ linuxBinary("amd64", "dist/release-cli_linux_amd64/release-cli"), linuxBinary("amd64", "dist/release-cli_linux_amd64_alt/release-cli"), linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), }, - wantErr: "duplicate linux/amd64 Binary record", + wantErr: `duplicate linux/amd64 Binary record for "release-cli"`, }, { name: "missing architecture", @@ -170,7 +180,7 @@ func TestSelectBinaries(t *testing.T) { records: []goprof.Record{ linuxBinary("amd64", "dist/release-cli_linux_amd64/release-cli"), }, - wantErr: "missing linux Binary record for arm64; found amd64", + wantErr: "missing linux/arm64 Binary record for release-cli; found amd64", }, { name: "unexpected extra architecture", @@ -201,13 +211,13 @@ func TestSelectBinaries(t *testing.T) { wantErr: "is not dist/-relative", }, { - name: "differing binary names", + name: "asymmetric name set", rootName: "dist", records: []goprof.Record{ linuxBinaryNamed("amd64", "dist/release-cli_linux_amd64/release-cli", "release-cli"), linuxBinaryNamed("arm64", "dist/release-cli_linux_arm64/other", "other"), }, - wantErr: `linux architecture binaries have different names "release-cli" and "other"`, + wantErr: `missing linux/amd64 Binary record for other`, }, { name: "empty binary name", @@ -227,6 +237,42 @@ func TestSelectBinaries(t *testing.T) { }, wantErr: "contains a path separator", }, + { + name: "selects two names sorted architecture-major then name-ascending", + rootName: "dist", + records: []goprof.Record{ + linuxBinaryNamed("arm64", "dist/server_linux_arm64/incus-server", "incus-server"), + linuxBinaryNamed("amd64", "dist/agent_linux_amd64/incus-agent", "incus-agent"), + linuxBinaryNamed("amd64", "dist/server_linux_amd64/incus-server", "incus-server"), + linuxBinaryNamed("arm64", "dist/agent_linux_arm64/incus-agent", "incus-agent"), + }, + want: []goprof.CanonicalBinary{ + canonical( + "amd64", + "dist/agent_linux_amd64/incus-agent", + "agent_linux_amd64/incus-agent", + "incus-agent", + ), + canonical( + "amd64", + "dist/server_linux_amd64/incus-server", + "server_linux_amd64/incus-server", + "incus-server", + ), + canonical( + "arm64", + "dist/agent_linux_arm64/incus-agent", + "agent_linux_arm64/incus-agent", + "incus-agent", + ), + canonical( + "arm64", + "dist/server_linux_arm64/incus-server", + "server_linux_arm64/incus-server", + "incus-server", + ), + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -290,8 +336,8 @@ func TestVerifyBinariesMapFS(t *testing.T) { t.Parallel() binaries := []goprof.CanonicalBinary{ - canonical("amd64", "dist/app_linux_amd64/app", "app_linux_amd64/app"), - canonical("arm64", "dist/app_linux_arm64/app", "app_linux_arm64/app"), + canonical("amd64", "dist/app_linux_amd64/app", "app_linux_amd64/app", "app"), + canonical("arm64", "dist/app_linux_arm64/app", "app_linux_arm64/app", "app"), } err := goprof.VerifyBinaries(fstest.MapFS{ @@ -317,8 +363,8 @@ func TestVerifyBinariesTempDir(t *testing.T) { writeExec(t, filepath.Join(root, arm64Path), []byte("arm64")) binaries := []goprof.CanonicalBinary{ - canonical("amd64", "dist/app_linux_amd64/app", filepath.ToSlash(amd64Path)), - canonical("arm64", "dist/app_linux_arm64/app", filepath.ToSlash(arm64Path)), + canonical("amd64", "dist/app_linux_amd64/app", filepath.ToSlash(amd64Path), "app"), + canonical("arm64", "dist/app_linux_arm64/app", filepath.ToSlash(arm64Path), "app"), } require.NoError(t, goprof.VerifyBinaries(os.DirFS(root), binaries)) @@ -363,12 +409,12 @@ func linuxBinaryNamed(arch, path, name string) goprof.Record { } // canonical builds a CanonicalBinary from already-valid test strings. -func canonical(arch, path, relative string) goprof.CanonicalBinary { +func canonical(arch, path, relative, name string) goprof.CanonicalBinary { return goprof.CanonicalBinary{ Arch: goprof.Arch(arch), Path: goprof.ArtifactPath(path), RelativePath: goprof.RelativePath(relative), - Name: goprof.BinaryName("release-cli"), + Name: goprof.BinaryName(name), } } @@ -399,7 +445,7 @@ func TestConfineRejectsDotDotOnDisk(t *testing.T) { }) err := goprof.VerifyBinaries(os.DirFS(root), []goprof.CanonicalBinary{ - canonical("amd64", "dist/../outside", "../outside"), + canonical("amd64", "dist/../outside", "../outside", "release-cli"), }) require.Error(t, err) assert.Contains(t, err.Error(), "escapes the dist root") diff --git a/internal/profile/goprof/doc.go b/internal/profile/goprof/doc.go index a7d155e..69ff572 100644 --- a/internal/profile/goprof/doc.go +++ b/internal/profile/goprof/doc.go @@ -1,8 +1,9 @@ // Package goprof selects canonical Linux binaries from GoReleaser artifacts.json. // // ParseArtifacts decodes the pinned GoReleaser record shape. SelectBinaries is -// a pure function that requires exactly one linux/amd64 Binary and one -// linux/arm64 Binary. Paths are relative to the --dist directory basename. +// a pure function that selects every linux/{amd64,arm64} Binary, requires the +// same nonempty name set on both architectures, and sorts architecture-major +// then name-ascending. Paths are relative to the --dist directory basename. // VerifyBinaries checks that each selected path is lexically confined, a // regular file, and owner-executable. package goprof diff --git a/internal/stage/checksum.go b/internal/stage/checksum.go index 28b1553..5daa35b 100644 --- a/internal/stage/checksum.go +++ b/internal/stage/checksum.go @@ -73,7 +73,7 @@ type Binary struct { Path string // RelativePath is Path with the leading dist root name stripped. RelativePath string - // Name is the binary filename, identical across selected platforms. + // Name is the binary filename for this architecture. Name string // Digest is the SHA-256 digest of the staged binary. Digest Digest diff --git a/internal/stage/image/build.go b/internal/stage/image/build.go index 7242ed8..6d1b580 100644 --- a/internal/stage/image/build.go +++ b/internal/stage/image/build.go @@ -11,6 +11,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "time" @@ -19,9 +20,7 @@ import ( const ( // BuildSchema is the versioned image-build result identifier. - BuildSchema = "release.dev/image-build/v1" - // applicationFile is the staged binary name inside each APK source tree. - applicationFile = "application" + BuildSchema = "release.dev/image-build/v2" // sourcesDir is the scratch directory that holds per-architecture trees. sourcesDir = "sources" // configurationDir is the output directory for copied build configs. @@ -46,7 +45,7 @@ const ( apkoConfigFile = "apko.yaml" // lockfileName is the Dir-relative apko lock output path. lockfileName = "apko.lock.json" - // checksumsFile is the GNU sha256sum listing of staged application files. + // checksumsFile is the GNU sha256sum listing of staged binary files. checksumsFile = "canonical-binaries.sha256" // apkIndexFile is the APK index basename written per architecture. apkIndexFile = "APKINDEX.tar.gz" @@ -58,7 +57,7 @@ const ( annotationVersion = "org.opencontainers.image.version" // annotationRevision is the OCI revision annotation key. annotationRevision = "org.opencontainers.image.revision" - // fileModeExecutable is the mode of each staged application file. + // fileModeExecutable is the mode of each staged binary file. fileModeExecutable = 0o755 // fileModeFile is the mode of copied configuration, checksums, and keys. fileModeFile = 0o644 @@ -70,7 +69,7 @@ const ( type BuildBinary struct { // Platform is the Linux OCI platform this binary was built for. Platform Platform - // Name is the binary filename, identical across platforms. + // Name is the binary filename for this platform. Name string // Path is the Source-relative confined path of the staged file. Path string @@ -80,8 +79,8 @@ type BuildBinary struct { // BuildInput is the staged binaries, configs, and roots [Build] consumes. type BuildInput struct { - // Binaries are the canonical Linux facts. [Build] requires exactly one - // [PlatformAMD64] and one [PlatformARM64] with identical names. + // Binaries are the canonical Linux facts. [Build] requires a nonempty + // identical name set on [PlatformAMD64] and [PlatformARM64]. Binaries []BuildBinary // Source is the extracted oci-input artifact root. Source fs.FS @@ -113,8 +112,8 @@ type BuildResult struct { Schema string `json:"schema"` // Version is the candidate MAJOR.MINOR.PATCH version. Version string `json:"version"` - // Binary is the staged binary filename shared by every platform. - Binary string `json:"binary"` + // Binaries are the staged binary filenames, sorted name-ascending. + Binaries []string `json:"binaries"` // Work is the scratch workspace path, [os.Root.Name] of the work root. Work string `json:"work"` // Output is the authoritative output path, [os.Root.Name] of the output root. @@ -133,8 +132,16 @@ type PackageResult struct { Arch string `json:"arch"` // Package is the output-relative APK path, packages//.apk. Package string `json:"package"` - // BinaryDigest is the verified canonical digest of the staged application. - BinaryDigest string `json:"binary_digest"` + // BinaryDigests are the verified canonical digests, sorted by name. + BinaryDigests []BinaryDigest `json:"binary_digests"` +} + +// BinaryDigest is one staged binary's verified digest. +type BinaryDigest struct { + // Name is the binary filename. + Name string `json:"name"` + // Digest is the verified canonical digest of the staged file. + Digest string `json:"digest"` } // stagedBinary is one platform's validated input plus its verified digest. @@ -143,41 +150,41 @@ type stagedBinary struct { platform Platform // arch is the APK architecture that platform maps onto. arch APKArch - // name is the binary filename shared with the other platform. + // name is the binary filename for this platform. name string // path is the Source-relative confined path. path string - // digest is the verified canonical digest of the staged application. + // digest is the verified canonical digest of the staged file. digest rel.Digest } // Build stages binaries, builds signed APK repositories, and composes an OCI layout. // // It fails closed before any write when the input is incomplete or malformed: -// a nil context, root, reader, or port; a binary list that is not exactly one -// [PlatformAMD64] and one [PlatformARM64] with identical nonempty names and -// confined paths; a zero version or digest; a BuildDate that is not RFC 3339; -// or an empty Namespace, SourceURL, Commit, or Reference. Both the work and -// output roots must contain no entries; a pre-existing entry is refused and -// names the populated root and one offending entry. Workspace and output -// directories are then created with [os.Root.Mkdir] and must not already -// exist; only the intermediate work/sources element uses MkdirAll. +// a nil context, root, reader, or port; a nonempty binary list whose name set +// is not identical across [PlatformAMD64] and [PlatformARM64]; a duplicate +// (platform, name) pair; a zero version or digest; a BuildDate that is not +// RFC 3339; or an empty Namespace, SourceURL, Commit, or Reference. Both the +// work and output roots must contain no entries; a pre-existing entry is +// refused and names the populated root and one offending entry. Workspace and +// output directories are then created with [os.Root.Mkdir] and must not +// already exist; only the intermediate work/sources element uses MkdirAll. // // Each binary is streamed once through SHA-256 into -// work/sources//application at mode 0755. The computed digest must +// work/sources// at mode 0755. The computed digest must // match the expected digest. The written file is then parsed as ELF and must // be a static 64-bit little-endian ET_EXEC for that architecture. // // After staging, Build writes work/vars.json, copies the Melange and apko // configs to output/configuration, and writes output/canonical-binaries.sha256 -// in GNU coreutils form with x86_64 first. It then calls [APKBuilder.Build] -// and requires the returned repository root and public key to match the -// request, plus exactly one nonempty APK and a nonempty APKINDEX.tar.gz per -// architecture. The public key is copied to the output root. [Composer.Build] -// then locks and writes the layout. Build requires apko.lock.json, -// layout/index.json, layout/oci-layout, and both architecture SBOMs to be -// nonempty regular files. It does not parse those files and does not write -// image-digest.txt. +// in GNU coreutils form with x86_64 first and names ascending within each +// architecture. It then calls [APKBuilder.Build] and requires the returned +// repository root and public key to match the request, plus exactly one +// nonempty APK and a nonempty APKINDEX.tar.gz per architecture. The public +// key is copied to the output root. [Composer.Build] then locks and writes +// the layout. Build requires apko.lock.json, layout/index.json, +// layout/oci-layout, and both architecture SBOMs to be nonempty regular +// files. It does not parse those files and does not write image-digest.txt. // // Platforms are processed in canonical order: linux/amd64, then linux/arm64. func Build(ctx context.Context, input BuildInput, apk APKBuilder, composer Composer) (BuildResult, error) { @@ -241,7 +248,7 @@ func Build(ctx context.Context, input BuildInput, apk APKBuilder, composer Compo return BuildResult{ Schema: BuildSchema, Version: input.Version.String(), - Binary: staged[0].name, + Binaries: stagedBinaryNames(staged), Work: input.Work.Name(), Output: input.Output.Name(), BuildDate: input.BuildDate, @@ -297,57 +304,98 @@ func validateBuild(ctx context.Context, input BuildInput, apk APKBuilder, compos return validateBinaries(input.Binaries) } -// validateBinaries requires exactly one binary per required platform. +// validateBinaries requires a nonempty identical name set on both platforms. func validateBinaries(binaries []BuildBinary) ([]stagedBinary, error) { - if len(binaries) != len(requiredPlatforms()) { - return nil, fmt.Errorf("need %d binaries, got %d", len(requiredPlatforms()), len(binaries)) + if len(binaries) == 0 { + return nil, errors.New("binaries is empty") } - byPlatform := make(map[Platform]BuildBinary, len(binaries)) - var name string + type platformName struct { + // platform is the Linux OCI platform. + platform Platform + // name is the binary filename. + name string + } + seen := make(map[platformName]struct{}, len(binaries)) + namesByPlatform := map[Platform]map[string]struct{}{ + PlatformAMD64: {}, + PlatformARM64: {}, + } + byKey := make(map[platformName]BuildBinary, len(binaries)) for _, binary := range binaries { platform, err := ParsePlatform(binary.Platform.String()) if err != nil { return nil, err } - if _, exists := byPlatform[platform]; exists { - return nil, fmt.Errorf("duplicate platform %s", platform) - } if err := validateBinaryName(binary.Name); err != nil { return nil, err } - if name == "" { - name = binary.Name - } else if binary.Name != name { - return nil, fmt.Errorf("binary name %q does not match %q", binary.Name, name) + key := platformName{platform: platform, name: binary.Name} + if _, exists := seen[key]; exists { + return nil, fmt.Errorf("duplicate platform %s name %q", platform, binary.Name) } + seen[key] = struct{}{} if !filepath.IsLocal(binary.Path) { return nil, fmt.Errorf("path %q is not a confined local path", binary.Path) } if binary.Digest == "" { - return nil, fmt.Errorf("digest for %s is empty", platform) + return nil, fmt.Errorf("digest for %s %q is empty", platform, binary.Name) } - byPlatform[platform] = binary + namesByPlatform[platform][binary.Name] = struct{}{} + byKey[key] = binary } - staged := make([]stagedBinary, 0, len(requiredPlatforms())) + names, err := matchingBinaryNames(namesByPlatform) + if err != nil { + return nil, err + } + + staged := make([]stagedBinary, 0, len(requiredPlatforms())*len(names)) for _, platform := range requiredPlatforms() { - binary, ok := byPlatform[platform] - if !ok { - return nil, fmt.Errorf("missing binary for %s", platform) + for _, name := range names { + binary := byKey[platformName{platform: platform, name: name}] + staged = append(staged, stagedBinary{ + platform: platform, + arch: platform.APKArch(), + name: binary.Name, + path: binary.Path, + digest: binary.Digest, + }) } - staged = append(staged, stagedBinary{ - platform: platform, - arch: platform.APKArch(), - name: binary.Name, - path: binary.Path, - digest: binary.Digest, - }) } return staged, nil } +// matchingBinaryNames requires every name on both required platforms. +func matchingBinaryNames(namesByPlatform map[Platform]map[string]struct{}) ([]string, error) { + union := make(map[string]struct{}) + for _, names := range namesByPlatform { + for name := range names { + union[name] = struct{}{} + } + } + ordered := make([]string, 0, len(union)) + for name := range union { + ordered = append(ordered, name) + } + slices.Sort(ordered) + if len(ordered) == 0 { + return nil, errors.New("binaries is empty") + } + + for _, platform := range requiredPlatforms() { + have := namesByPlatform[platform] + for _, name := range ordered { + if _, ok := have[name]; !ok { + return nil, fmt.Errorf("missing binary %q for %s", name, platform) + } + } + } + + return ordered, nil +} + // validateBinaryName rejects an empty name or a name that contains a path separator. func validateBinaryName(name string) error { if name == "" { @@ -424,7 +472,7 @@ func stageBinary(source fs.FS, work *os.Root, binary *stagedBinary) error { } defer src.Close() - stagedPath := path.Join(sourcesDir, binary.arch.String(), applicationFile) + stagedPath := path.Join(sourcesDir, binary.arch.String(), binary.name) dest, err := work.OpenFile(stagedPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileModeExecutable) if err != nil { return fmt.Errorf("create %s: %w", stagedPath, err) @@ -517,7 +565,7 @@ func writeChecksums(output *os.Root, staged []stagedBinary) error { if !ok { return fmt.Errorf("digest %s is missing the sha256: prefix", binary.digest) } - relPath := path.Join(sourcesDir, binary.arch.String(), applicationFile) + relPath := path.Join(sourcesDir, binary.arch.String(), binary.name) b.WriteString(hexDigest) b.WriteString(" ") b.WriteString(relPath) @@ -532,8 +580,13 @@ func writeChecksums(output *os.Root, staged []stagedBinary) error { // apkRequest builds the [APKBuilder] request from validated input. func apkRequest(input BuildInput, staged []stagedBinary) APKBuildRequest { - sources := make([]APKBuildSource, 0, len(staged)) + seen := make(map[APKArch]struct{}, len(requiredPlatforms())) + sources := make([]APKBuildSource, 0, len(requiredPlatforms())) for _, binary := range staged { + if _, exists := seen[binary.arch]; exists { + continue + } + seen[binary.arch] = struct{}{} sources = append(sources, APKBuildSource{ Arch: binary.arch, Dir: filepath.Join(input.Work.Name(), sourcesDir, binary.arch.String()), @@ -569,23 +622,56 @@ func checkRepositories( return nil, fmt.Errorf("apk public key %q does not match %q", repos.PublicKey, wantKey) } - packages := make([]PackageResult, 0, len(staged)) - for _, binary := range staged { - pkg, err := requirePackage(output, binary.arch) + packages := make([]PackageResult, 0, len(requiredPlatforms())) + for _, platform := range requiredPlatforms() { + arch := platform.APKArch() + pkg, err := requirePackage(output, arch) if err != nil { return nil, err } packages = append(packages, PackageResult{ - Platform: binary.platform.String(), - Arch: binary.arch.String(), - Package: pkg, - BinaryDigest: binary.digest.String(), + Platform: platform.String(), + Arch: arch.String(), + Package: pkg, + BinaryDigests: packageBinaryDigests(staged, platform), }) } return packages, nil } +// packageBinaryDigests returns name-sorted digests for platform. +func packageBinaryDigests(staged []stagedBinary, platform Platform) []BinaryDigest { + out := make([]BinaryDigest, 0) + for _, binary := range staged { + if binary.platform != platform { + continue + } + out = append(out, BinaryDigest{Name: binary.name, Digest: binary.digest.String()}) + } + slices.SortFunc(out, func(a, b BinaryDigest) int { + return strings.Compare(a.Name, b.Name) + }) + + return out +} + +// stagedBinaryNames returns unique binary names in ascending order. +func stagedBinaryNames(staged []stagedBinary) []string { + seen := make(map[string]struct{}, len(staged)) + var names []string + for _, binary := range staged { + if _, exists := seen[binary.name]; exists { + continue + } + seen[binary.name] = struct{}{} + names = append(names, binary.name) + } + slices.Sort(names) + + return names +} + // requirePackage requires a nonempty APKINDEX and exactly one nonempty APK for arch. func requirePackage(output *os.Root, arch APKArch) (string, error) { index := path.Join(packagesDir, arch.String(), apkIndexFile) diff --git a/internal/stage/image/build_test.go b/internal/stage/image/build_test.go index 58ec860..69d52df 100644 --- a/internal/stage/image/build_test.go +++ b/internal/stage/image/build_test.go @@ -57,6 +57,60 @@ func TestBuildHappyPath(t *testing.T) { assertStagedLayout(t, tc) } +func TestBuildMultipleBinaries(t *testing.T) { + t.Parallel() + + tc := newBuildTest(t) + agent := "incus-agent" + server := "incus-server" + serverAMD64 := tc.amd64 + serverARM64 := tc.arm64 + + agentAMD64Path := "agent_linux_amd64/incus-agent" + agentARM64Path := "agent_linux_arm64/incus-agent" + serverAMD64Path := "server_linux_amd64/incus-server" + serverARM64Path := "server_linux_arm64/incus-server" + tc.input.Source = fstest.MapFS{ + agentAMD64Path: {Data: tc.amd64}, + agentARM64Path: {Data: tc.arm64}, + serverAMD64Path: {Data: serverAMD64}, + serverARM64Path: {Data: serverARM64}, + } + tc.input.Binaries = []image.BuildBinary{ + {Platform: image.PlatformAMD64, Name: agent, Path: agentAMD64Path, Digest: digestOf(t, tc.amd64)}, + {Platform: image.PlatformAMD64, Name: server, Path: serverAMD64Path, Digest: digestOf(t, serverAMD64)}, + {Platform: image.PlatformARM64, Name: server, Path: serverARM64Path, Digest: digestOf(t, serverARM64)}, + {Platform: image.PlatformARM64, Name: agent, Path: agentARM64Path, Digest: digestOf(t, tc.arm64)}, + } + + wantAPK := wantAPKRequest(tc) + wantCompose := wantComposeRequest(tc) + expectSuccessfulAPK(t, tc, wantAPK) + expectSuccessfulCompose(t, tc, wantCompose) + + got, err := image.Build(context.Background(), tc.input, tc.apk, tc.composer) + require.NoError(t, err) + assert.Equal(t, []string{agent, server}, got.Binaries) + assert.Equal(t, []image.BinaryDigest{ + {Name: agent, Digest: digestOf(t, tc.amd64).String()}, + {Name: server, Digest: digestOf(t, serverAMD64).String()}, + }, got.Packages[0].BinaryDigests) + assertMode(t, tc.work, filepath.Join("sources", "x86_64", agent), 0o755) + assertMode(t, tc.work, filepath.Join("sources", "x86_64", server), 0o755) + assertMode(t, tc.work, filepath.Join("sources", "aarch64", agent), 0o755) + assertMode(t, tc.work, filepath.Join("sources", "aarch64", server), 0o755) + + checksums, err := tc.output.ReadFile("canonical-binaries.sha256") + require.NoError(t, err) + assert.Equal(t, + hexDigest(t, tc.amd64)+" sources/x86_64/"+agent+"\n"+ + hexDigest(t, serverAMD64)+" sources/x86_64/"+server+"\n"+ + hexDigest(t, tc.arm64)+" sources/aarch64/"+agent+"\n"+ + hexDigest(t, serverARM64)+" sources/aarch64/"+server+"\n", + string(checksums), + ) +} + func TestBuildDigestMismatch(t *testing.T) { t.Parallel() @@ -546,10 +600,10 @@ func TestBuildRejectsInvalidInput(t *testing.T) { }(), apk: valid.apk, compose: valid.composer, - wantErr: "need 2 binaries, got 1", + wantErr: `missing binary "release-cli" for linux/arm64`, }, { - name: "mismatched names", + name: "asymmetric names", ctx: context.Background(), input: func() image.BuildInput { in := valid.input @@ -560,7 +614,7 @@ func TestBuildRejectsInvalidInput(t *testing.T) { }(), apk: valid.apk, compose: valid.composer, - wantErr: "binary name", + wantErr: `missing binary "other" for linux/amd64`, }, { name: "escaping path", @@ -720,22 +774,28 @@ func wantBuildResult(t *testing.T, tc *buildTest) image.BuildResult { return image.BuildResult{ Schema: image.BuildSchema, Version: testVersion, - Binary: testBinaryName, + Binaries: []string{testBinaryName}, Work: tc.work.Name(), Output: tc.output.Name(), BuildDate: testBuildDate, Packages: []image.PackageResult{ { - Platform: image.PlatformAMD64.String(), - Arch: image.ArchX8664.String(), - Package: path.Join("packages", "x86_64", testAMD64APK), - BinaryDigest: digestOf(t, tc.amd64).String(), + Platform: image.PlatformAMD64.String(), + Arch: image.ArchX8664.String(), + Package: path.Join("packages", "x86_64", testAMD64APK), + BinaryDigests: []image.BinaryDigest{{ + Name: testBinaryName, + Digest: digestOf(t, tc.amd64).String(), + }}, }, { - Platform: image.PlatformARM64.String(), - Arch: image.ArchAArch64.String(), - Package: path.Join("packages", "aarch64", testARM64APK), - BinaryDigest: digestOf(t, tc.arm64).String(), + Platform: image.PlatformARM64.String(), + Arch: image.ArchAArch64.String(), + Package: path.Join("packages", "aarch64", testARM64APK), + BinaryDigests: []image.BinaryDigest{{ + Name: testBinaryName, + Digest: digestOf(t, tc.arm64).String(), + }}, }, }, } @@ -793,8 +853,8 @@ func expectIdleComposer(tc *buildTest) { func assertStagedLayout(t *testing.T, tc *buildTest) { t.Helper() - assertMode(t, tc.work, filepath.Join("sources", "x86_64", "application"), 0o755) - assertMode(t, tc.work, filepath.Join("sources", "aarch64", "application"), 0o755) + assertMode(t, tc.work, filepath.Join("sources", "x86_64", testBinaryName), 0o755) + assertMode(t, tc.work, filepath.Join("sources", "aarch64", testBinaryName), 0o755) assertMode(t, tc.output, filepath.Join("configuration", "melange.yaml"), 0o644) assertMode(t, tc.output, filepath.Join("configuration", "apko.yaml"), 0o644) assertMode(t, tc.output, "apk-signing.rsa.pub", 0o644) @@ -823,8 +883,8 @@ func assertStagedLayout(t *testing.T, tc *buildTest) { func wantChecksums(t *testing.T, tc *buildTest) string { t.Helper() - return hexDigest(t, tc.amd64) + " sources/x86_64/application\n" + - hexDigest(t, tc.arm64) + " sources/aarch64/application\n" + return hexDigest(t, tc.amd64) + " sources/x86_64/" + testBinaryName + "\n" + + hexDigest(t, tc.arm64) + " sources/aarch64/" + testBinaryName + "\n" } // assertMode requires name under root to be a regular file with perm. diff --git a/internal/stage/image/doc.go b/internal/stage/image/doc.go index 5d9f840..c6da5d2 100644 --- a/internal/stage/image/doc.go +++ b/internal/stage/image/doc.go @@ -6,6 +6,6 @@ // [Composer]. [VerifyLayout] reads the on-disk layout byte for byte and checks // the index, manifests, configs, and layer binary against [ExpectedImage]. // [VerifySBOMs] checks the architecture SPDX documents. [CanonicalDigests] -// hashes work/sources//application. The package does not import the +// hashes work/sources//. The package does not import the // staging projection; callers convert that wire type into [BuildInput]. package image diff --git a/internal/stage/image/layout.go b/internal/stage/image/layout.go index d77bb12..a6ed424 100644 --- a/internal/stage/image/layout.go +++ b/internal/stage/image/layout.go @@ -19,7 +19,7 @@ import ( const ( // VerifySchema is the versioned image-verify result identifier. - VerifySchema = "release.dev/image-verify/v1" + VerifySchema = "release.dev/image-verify/v2" // bytesPerKiB is the number of bytes in a kibibyte. bytesPerKiB = 1024 // kibibytesPerMiB is the number of kibibytes in a mebibyte. diff --git a/internal/stage/image/layout_test.go b/internal/stage/image/layout_test.go index 1d364c0..570d747 100644 --- a/internal/stage/image/layout_test.go +++ b/internal/stage/image/layout_test.go @@ -683,12 +683,12 @@ func expectedImage(t *testing.T) image.ExpectedImage { return image.ExpectedImage{ Version: version, - Binary: testBinaryName, + Binaries: []string{testBinaryName}, Revision: testCommit, Source: testSourceURL, - Canonical: map[image.APKArch]rel.Digest{ - image.ArchX8664: digestOf(t, []byte(testAMD64Binary)), - image.ArchAArch64: digestOf(t, []byte(testARM64Binary)), + Canonical: map[image.APKArch]map[string]rel.Digest{ + image.ArchX8664: {testBinaryName: digestOf(t, []byte(testAMD64Binary))}, + image.ArchAArch64: {testBinaryName: digestOf(t, []byte(testARM64Binary))}, }, } } diff --git a/internal/stage/image/ports.go b/internal/stage/image/ports.go index d8c7ada..5fe9ad8 100644 --- a/internal/stage/image/ports.go +++ b/internal/stage/image/ports.go @@ -29,11 +29,11 @@ type Composer interface { Build(ctx context.Context, request ComposeRequest) error } -// APKBuildSource is one architecture's staged application tree. +// APKBuildSource is one architecture's staged binary tree. type APKBuildSource struct { // Arch is the APK architecture built from this source tree. Arch APKArch - // Dir is the absolute directory containing the staged file named "application". + // Dir is the absolute directory containing the staged binary files. Dir string } diff --git a/internal/stage/image/verify.go b/internal/stage/image/verify.go index 2c07943..059f196 100644 --- a/internal/stage/image/verify.go +++ b/internal/stage/image/verify.go @@ -68,14 +68,15 @@ func checkedAnnotationKeys() []string { type ExpectedImage struct { // Version is the candidate MAJOR.MINOR.PATCH release. Version rel.Version - // Binary is the filename that must appear at /usr/bin/ in each layer. - Binary string + // Binaries are the filenames that must appear at /usr/bin/ in each layer. + Binaries []string // Revision is the expected org.opencontainers.image.revision, typically GITHUB_SHA. Revision string // Source is the expected org.opencontainers.image.source URL. Source string - // Canonical maps each APK architecture to the digest of sources//application. - Canonical map[APKArch]rel.Digest + // Canonical maps each APK architecture onto per-name digests of + // sources//. + Canonical map[APKArch]map[string]rel.Digest } // VerifiedImage is a layout that satisfied [VerifyLayout]. @@ -98,8 +99,8 @@ type verifiedPlatform struct { config rel.Digest // layer is the digest of the single layer blob. layer rel.Digest - // binaryDigest is the streamed SHA-256 of usr/bin/ in that layer. - binaryDigest rel.Digest + // binaryDigests are the streamed SHA-256 of each usr/bin/ in that layer. + binaryDigests []BinaryDigest } // VerifiedPlatform is one platform recorded by [VerifyResult]. @@ -114,8 +115,8 @@ type VerifiedPlatform struct { Config string `json:"config"` // Layer is the digest of the single layer blob. Layer string `json:"layer"` - // BinaryDigest is the streamed SHA-256 of usr/bin/ in that layer. - BinaryDigest string `json:"binary_digest"` + // BinaryDigests are the streamed SHA-256 of each usr/bin/ in that layer. + BinaryDigests []BinaryDigest `json:"binary_digests"` } // VerifyResult is the versioned document produced by [VerifiedImage.Result]. @@ -124,8 +125,8 @@ type VerifyResult struct { Schema string `json:"schema"` // Version is the candidate MAJOR.MINOR.PATCH version. Version string `json:"version"` - // Binary is the staged binary filename shared by every platform. - Binary string `json:"binary"` + // Binaries are the staged binary filenames, sorted name-ascending. + Binaries []string `json:"binaries"` // IndexDigest is SHA-256 over the exact index.json bytes. IndexDigest string `json:"index_digest"` // Platforms are the verified platforms in canonical order: linux/amd64, linux/arm64. @@ -159,18 +160,18 @@ type sbomPackage struct { // revision, source, and version must equal expected. Each platform's // manifest annotations and config labels must equal those six index // values. Each config must have architecture equal to the descriptor -// architecture, os linux, Entrypoint ["/usr/bin/"], and -// User "65532". The single layer is then streamed, never buffered: gzip -// when the media type ends in +gzip, otherwise plain tar. The entry -// usr/bin/ (a leading "./" is accepted) must be a regular -// file whose Mode low twelve bits are exactly 0755, with uid/gid 0/0, and -// a declared tar Size of at most -// [maxBinaryBytes]; its content is hashed through SHA-256 with [io.CopyN] +// architecture, os linux, Entrypoint ["/usr/bin/"] for some expected +// name, and User "65532". The single layer is then streamed once, never +// buffered: gzip when the media type ends in +gzip, otherwise plain tar. +// Every expected name must appear exactly once as usr/bin/ (a leading +// "./" is accepted), a regular file whose Mode low twelve bits are exactly +// 0755, with uid/gid 0/0, and a declared tar Size of at most +// [maxBinaryBytes]; each payload is hashed through SHA-256 with [io.CopyN] // bounded by that Size and must equal expected.Canonical for that -// platform's APK architecture. A missing, duplicate, non-regular, -// oversized, or mismatched entry is a verification failure that names -// the platform. Layer media types other than the OCI tar and tar+gzip -// types are rejected. +// platform's APK architecture and name. A missing, duplicate, leftover, +// non-regular, oversized, or mismatched entry is a verification failure +// that names the platform. Layer media types other than the OCI tar and +// tar+gzip types are rejected. // // VerifyLayout does not inspect SBOMs and does not write image-digest.txt. // Call [VerifySBOMs] and the command layer for those. @@ -212,19 +213,19 @@ func (image VerifiedImage) Result(expected ExpectedImage) VerifyResult { platforms := make([]VerifiedPlatform, 0, len(image.platforms)) for _, platform := range image.platforms { platforms = append(platforms, VerifiedPlatform{ - Platform: platform.platform.String(), - Arch: platform.arch.String(), - Manifest: platform.manifest.String(), - Config: platform.config.String(), - Layer: platform.layer.String(), - BinaryDigest: platform.binaryDigest.String(), + Platform: platform.platform.String(), + Arch: platform.arch.String(), + Manifest: platform.manifest.String(), + Config: platform.config.String(), + Layer: platform.layer.String(), + BinaryDigests: platform.binaryDigests, }) } return VerifyResult{ Schema: VerifySchema, Version: expected.Version.String(), - Binary: expected.Binary, + Binaries: slices.Clone(expected.Binaries), IndexDigest: image.digest.String(), Platforms: platforms, } @@ -261,31 +262,41 @@ func VerifySBOMs(fsys fs.FS, version rel.Version, arches []APKArch) error { return nil } -// CanonicalDigests streams sources//application through SHA-256. +// CanonicalDigests streams sources// through SHA-256. // -// work is a [fs.FS] rooted at the scratch workspace. Each architecture is -// hashed independently and never buffered. A missing path or a non-regular -// entry is an error that names the file. The returned map is keyed by the -// supplied architectures in the order they were hashed. -func CanonicalDigests(work fs.FS, arches []APKArch) (map[APKArch]rel.Digest, error) { +// work is a [fs.FS] rooted at the scratch workspace. Each architecture and +// name is hashed independently and never buffered. A missing path or a +// non-regular entry is an error that names the file. The returned map is +// keyed by architecture, then binary name. +func CanonicalDigests(work fs.FS, arches []APKArch, names []string) (map[APKArch]map[string]rel.Digest, error) { if work == nil { return nil, errors.New("work filesystem is nil") } if len(arches) == 0 { return nil, errors.New("canonical architecture list is empty") } + if len(names) == 0 { + return nil, errors.New("canonical binary name list is empty") + } - digests := make(map[APKArch]rel.Digest, len(arches)) + digests := make(map[APKArch]map[string]rel.Digest, len(arches)) for _, arch := range arches { if arch == "" { return nil, errors.New("canonical architecture is empty") } - name := path.Join(sourcesDir, arch.String(), applicationFile) - digest, err := hashRegularFile(work, name) - if err != nil { - return nil, err + byName := make(map[string]rel.Digest, len(names)) + for _, name := range names { + if err := validateBinaryName(name); err != nil { + return nil, err + } + pathName := path.Join(sourcesDir, arch.String(), name) + digest, err := hashRegularFile(work, pathName) + if err != nil { + return nil, err + } + byName[name] = digest } - digests[arch] = digest + digests[arch] = byName } return digests, nil @@ -293,11 +304,18 @@ func CanonicalDigests(work fs.FS, arches []APKArch) (map[APKArch]rel.Digest, err // validateExpected rejects incomplete [ExpectedImage] facts. func validateExpected(expected ExpectedImage) error { - if expected.Binary == "" { - return errors.New("binary name is empty") + if len(expected.Binaries) == 0 { + return errors.New("binary name list is empty") } - if strings.ContainsAny(expected.Binary, `/\`) { - return fmt.Errorf("binary name %q contains a path separator", expected.Binary) + seen := make(map[string]struct{}, len(expected.Binaries)) + for _, name := range expected.Binaries { + if err := validateBinaryName(name); err != nil { + return err + } + if _, exists := seen[name]; exists { + return fmt.Errorf("duplicate binary name %q", name) + } + seen[name] = struct{}{} } if expected.Revision == "" { return errors.New("revision is empty") @@ -359,7 +377,7 @@ func checkIndexAnnotations(annotations map[string]string, expected ExpectedImage return nil } -// verifyPlatform checks one platform's annotations, config, and layer binary. +// verifyPlatform checks one platform's annotations, config, and layer binaries. func verifyPlatform( fsys fs.FS, platform LayoutPlatform, @@ -387,18 +405,18 @@ func verifyPlatform( platform.Platform.APKArch(), ) } - binaryDigest, err := verifyLayerBinary(fsys, platform, expected.Binary, canonical) + binaryDigests, err := verifyLayerBinaries(fsys, platform, expected.Binaries, canonical) if err != nil { return verifiedPlatform{}, err } return verifiedPlatform{ - platform: platform.Platform, - arch: platform.Platform.APKArch(), - manifest: platform.Manifest, - config: platform.Config, - layer: platform.Layer, - binaryDigest: binaryDigest, + platform: platform.Platform, + arch: platform.Platform.APKArch(), + manifest: platform.Manifest, + config: platform.Config, + layer: platform.Layer, + binaryDigests: binaryDigests, }, nil } @@ -458,14 +476,11 @@ func checkImageConfig( if config.OS != "linux" { return fmt.Errorf("%s config os is %q, want linux", platform.Platform, config.OS) } - - wantEntrypoint := []string{"/usr/bin/" + expected.Binary} - if !slices.Equal(config.Config.Entrypoint, wantEntrypoint) { + if !entrypointMatchesExpected(config.Config.Entrypoint, expected.Binaries) { return fmt.Errorf( - "%s config Entrypoint is %q, want %q", + "%s config Entrypoint is %q, want [/usr/bin/] for some expected name", platform.Platform, config.Config.Entrypoint, - wantEntrypoint, ) } if config.Config.User != expectedConfigUser { @@ -480,78 +495,100 @@ func checkImageConfig( return checkEqualAnnotations(platform.Platform, "config label", config.Config.Labels, indexAnnotations) } -// verifyLayerBinary streams the layer and hashes usr/bin/. -func verifyLayerBinary( +// entrypointMatchesExpected reports whether entrypoint is ["/usr/bin/"] +// for some expected name. +func entrypointMatchesExpected(entrypoint, names []string) bool { + if len(entrypoint) != 1 { + return false + } + for _, name := range names { + if entrypoint[0] == "/usr/bin/"+name { + return true + } + } + + return false +} + +// verifyLayerBinaries streams the layer once and hashes every expected usr/bin/. +func verifyLayerBinaries( fsys fs.FS, platform LayoutPlatform, - binary string, - canonical rel.Digest, -) (rel.Digest, error) { + names []string, + canonical map[string]rel.Digest, +) ([]BinaryDigest, error) { if err := checkLayerMedia(platform); err != nil { - return "", err + return nil, err } name, err := blobPath(platform.Layer) if err != nil { - return "", fmt.Errorf("%s layer: %w", platform.Platform, err) + return nil, fmt.Errorf("%s layer: %w", platform.Platform, err) } file, err := fsys.Open(name) if err != nil { - return "", fmt.Errorf("%s open layer %s: %w", platform.Platform, name, err) + return nil, fmt.Errorf("%s open layer %s: %w", platform.Platform, name, err) } defer file.Close() stream := io.Reader(file) if strings.HasSuffix(platform.LayerMedia, gzipMediaSuffix) { reader, gzipErr := gzip.NewReader(file) if gzipErr != nil { - return "", fmt.Errorf("%s layer %s: gzip: %w", platform.Platform, name, gzipErr) + return nil, fmt.Errorf("%s layer %s: gzip: %w", platform.Platform, name, gzipErr) } defer reader.Close() stream = reader } - digest, err := hashBinaryEntry(stream, binary) + got, err := hashBinaryEntries(stream, names) if err != nil { - return "", fmt.Errorf("%s: %w", platform.Platform, err) - } - if digest != canonical { - return "", fmt.Errorf( - "%s image binary has digest %s, expected %s", - platform.Platform, - digest, - canonical, - ) + return nil, fmt.Errorf("%s: %w", platform.Platform, err) } - return digest, nil -} - -// checkLayerMedia rejects a layer media type that is neither tar nor tar+gzip. -func checkLayerMedia(platform LayoutPlatform) error { - switch platform.LayerMedia { - case layerMediaTar, layerMediaGzip: - return nil - default: - return fmt.Errorf( - "%s layer media type is %q, want %q or %q", - platform.Platform, - platform.LayerMedia, - layerMediaTar, - layerMediaGzip, - ) + out := make([]BinaryDigest, 0, len(names)) + ordered := slices.Clone(names) + slices.Sort(ordered) + for _, binaryName := range ordered { + digest, ok := got[binaryName] + if !ok { + return nil, fmt.Errorf("%s: layer is missing usr/bin/%s", platform.Platform, binaryName) + } + want, exists := canonical[binaryName] + if !exists { + return nil, fmt.Errorf( + "%s is missing a canonical digest for %q", + platform.Platform, + binaryName, + ) + } + if digest != want { + return nil, fmt.Errorf( + "%s image binary %q has digest %s, expected %s", + platform.Platform, + binaryName, + digest, + want, + ) + } + out = append(out, BinaryDigest{Name: binaryName, Digest: digest.String()}) } + + return out, nil } -// hashBinaryEntry finds usr/bin/ once and hashes its content. +// hashBinaryEntries finds each expected usr/bin/ once and hashes its content. // // The payload is copied with [io.CopyN] using the tar header Size, which // must be between 0 and [maxBinaryBytes] inclusive. The layer stream is -// never buffered. -func hashBinaryEntry(stream io.Reader, binary string) (rel.Digest, error) { - want := path.Join("usr/bin", binary) +// never buffered. Duplicate expected entries fail. Expected names absent +// from the layer fail as leftovers. +func hashBinaryEntries(stream io.Reader, names []string) (map[string]rel.Digest, error) { + want := make(map[string]struct{}, len(names)) + for _, name := range names { + want[name] = struct{}{} + } reader := tar.NewReader(stream) - found := false - var digest rel.Digest + found := make(map[string]rel.Digest, len(names)) for { header, err := reader.Next() @@ -559,26 +596,32 @@ func hashBinaryEntry(stream io.Reader, binary string) (rel.Digest, error) { break } if err != nil { - return "", fmt.Errorf("read layer: %w", err) + return nil, fmt.Errorf("read layer: %w", err) } - if !isBinaryEntry(header.Name, want) { + entryName, ok := layerBinaryName(header.Name) + if !ok { continue } - if found { - return "", fmt.Errorf("layer lists %s more than once", want) + if _, expected := want[entryName]; !expected { + continue + } + wantPath := path.Join("usr/bin", entryName) + if _, exists := found[entryName]; exists { + return nil, fmt.Errorf("layer lists %s more than once", wantPath) } - hashed, hashErr := hashMatchedEntry(reader, header, want) + hashed, hashErr := hashMatchedEntry(reader, header, wantPath) if hashErr != nil { - return "", hashErr + return nil, hashErr } - digest = hashed - found = true + found[entryName] = hashed } - if !found { - return "", fmt.Errorf("layer is missing %s", want) + for _, name := range names { + if _, ok := found[name]; !ok { + return nil, fmt.Errorf("layer is missing %s", path.Join("usr/bin", name)) + } } - return digest, nil + return found, nil } // hashMatchedEntry validates header and streams the regular-file payload. @@ -630,11 +673,35 @@ func checkBinaryHeader(header *tar.Header, want string) error { return nil } -// isBinaryEntry reports whether name is usr/bin/ with an optional "./". -func isBinaryEntry(name, want string) bool { +// checkLayerMedia rejects a layer media type that is neither tar nor tar+gzip. +func checkLayerMedia(platform LayoutPlatform) error { + switch platform.LayerMedia { + case layerMediaTar, layerMediaGzip: + return nil + default: + return fmt.Errorf( + "%s layer media type is %q, want %q or %q", + platform.Platform, + platform.LayerMedia, + layerMediaTar, + layerMediaGzip, + ) + } +} + +// layerBinaryName reports the filename when name is usr/bin/ with an optional "./". +func layerBinaryName(name string) (string, bool) { cleaned := strings.TrimPrefix(name, "./") + const prefix = "usr/bin/" + if !strings.HasPrefix(cleaned, prefix) { + return "", false + } + rest := strings.TrimPrefix(cleaned, prefix) + if rest == "" || strings.ContainsAny(rest, `/\`) { + return "", false + } - return cleaned == want + return rest, true } // tarTypeName is a short label for a tar Typeflag used in error text. diff --git a/internal/stage/image/verify_test.go b/internal/stage/image/verify_test.go index 9f131f5..9a8ca28 100644 --- a/internal/stage/image/verify_test.go +++ b/internal/stage/image/verify_test.go @@ -31,7 +31,7 @@ func TestVerifyLayoutHappyPath(t *testing.T) { result := got.Result(expected) assert.Equal(t, image.VerifySchema, result.Schema) assert.Equal(t, testVersion, result.Version) - assert.Equal(t, testBinaryName, result.Binary) + assert.Equal(t, []string{testBinaryName}, result.Binaries) assert.Equal(t, digestOf(t, fixture.indexBytes).String(), result.IndexDigest) require.Len(t, result.Platforms, 2) assert.Equal(t, "linux/amd64", result.Platforms[0].Platform) @@ -39,10 +39,16 @@ func TestVerifyLayoutHappyPath(t *testing.T) { assert.Equal(t, fixture.amd64.Manifest.String(), result.Platforms[0].Manifest) assert.Equal(t, fixture.amd64.Config.String(), result.Platforms[0].Config) assert.Equal(t, fixture.amd64.Layer.String(), result.Platforms[0].Layer) - assert.Equal(t, digestOf(t, []byte(testAMD64Binary)).String(), result.Platforms[0].BinaryDigest) + assert.Equal(t, []image.BinaryDigest{{ + Name: testBinaryName, + Digest: digestOf(t, []byte(testAMD64Binary)).String(), + }}, result.Platforms[0].BinaryDigests) assert.Equal(t, "linux/arm64", result.Platforms[1].Platform) assert.Equal(t, "aarch64", result.Platforms[1].Arch) - assert.Equal(t, digestOf(t, []byte(testARM64Binary)).String(), result.Platforms[1].BinaryDigest) + assert.Equal(t, []image.BinaryDigest{{ + Name: testBinaryName, + Digest: digestOf(t, []byte(testARM64Binary)).String(), + }}, result.Platforms[1].BinaryDigests) } func TestVerifyLayoutAcceptsPlainTarLayer(t *testing.T) { @@ -90,8 +96,11 @@ func TestVerifyLayoutIgnoresPrefixedDecoyEntry(t *testing.T) { require.NoError(t, err) assert.Equal( t, - digestOf(t, []byte(testAMD64Binary)).String(), - got.Result(expectedImage(t)).Platforms[0].BinaryDigest, + []image.BinaryDigest{{ + Name: testBinaryName, + Digest: digestOf(t, []byte(testAMD64Binary)).String(), + }}, + got.Result(expectedImage(t)).Platforms[0].BinaryDigests, ) } @@ -124,6 +133,57 @@ func TestVerifyLayoutCanonicalOrder(t *testing.T) { assert.Equal(t, "linux/arm64", result.Platforms[1].Platform) } +func TestVerifyLayoutMultipleBinaries(t *testing.T) { + t.Parallel() + + const agent = "incus-agent" + const server = "incus-server" + amd64Agent := []byte("amd64-agent") + amd64Server := []byte("amd64-server") + arm64Agent := []byte("arm64-agent") + arm64Server := []byte("arm64-server") + + fixture := newVerifyLayout(t, mutateSpec(func(spec *layoutSpec) { + spec.platforms[0].entrypoint = []string{"/usr/bin/" + agent} + spec.platforms[0].layers[0].entries = []tarEntry{ + {name: "usr/bin/" + server, body: amd64Server, mode: 0o755}, + {name: "usr/bin/" + agent, body: amd64Agent, mode: 0o755}, + } + spec.platforms[1].entrypoint = []string{"/usr/bin/" + agent} + spec.platforms[1].layers[0].entries = []tarEntry{ + {name: "usr/bin/" + agent, body: arm64Agent, mode: 0o755}, + {name: "usr/bin/" + server, body: arm64Server, mode: 0o755}, + } + })) + version, err := rel.ParseVersion(testVersion) + require.NoError(t, err) + expected := image.ExpectedImage{ + Version: version, + Binaries: []string{agent, server}, + Revision: testCommit, + Source: testSourceURL, + Canonical: map[image.APKArch]map[string]rel.Digest{ + image.ArchX8664: { + agent: digestOf(t, amd64Agent), + server: digestOf(t, amd64Server), + }, + image.ArchAArch64: { + agent: digestOf(t, arm64Agent), + server: digestOf(t, arm64Server), + }, + }, + } + + got, err := image.VerifyLayout(fixture.files, expected) + require.NoError(t, err) + result := got.Result(expected) + assert.Equal(t, []string{agent, server}, result.Binaries) + assert.Equal(t, []image.BinaryDigest{ + {Name: agent, Digest: digestOf(t, amd64Agent).String()}, + {Name: server, Digest: digestOf(t, amd64Server).String()}, + }, result.Platforms[0].BinaryDigests) +} + func TestVerifyLayoutErrors(t *testing.T) { t.Parallel() @@ -485,7 +545,7 @@ func TestVerifyLayoutErrors(t *testing.T) { spec.platforms[0].layers[0].entries[0].body = []byte("tampered") })).files, expected: expectedImage, - wantErr: "linux/amd64 image binary has digest", + wantErr: `linux/amd64 image binary "release-cli" has digest`, }, { name: "unsupported layer media type", @@ -496,16 +556,32 @@ func TestVerifyLayoutErrors(t *testing.T) { wantErr: "linux/amd64 layer media type is", }, { - name: "empty binary name", + name: "empty binary name list", files: newVerifyLayout(t, defaultLayoutSpec()).files, expected: func(t *testing.T) image.ExpectedImage { t.Helper() expected := expectedImage(t) - expected.Binary = "" + expected.Binaries = nil return expected }, - wantErr: "binary name is empty", + wantErr: "binary name list is empty", + }, + { + name: "entrypoint not in expected set", + files: newVerifyLayout(t, defaultLayoutSpec()).files, + expected: func(t *testing.T) image.ExpectedImage { + t.Helper() + expected := expectedImage(t) + expected.Binaries = []string{"other"} + expected.Canonical = map[image.APKArch]map[string]rel.Digest{ + image.ArchX8664: {"other": digestOf(t, []byte(testAMD64Binary))}, + image.ArchAArch64: {"other": digestOf(t, []byte(testARM64Binary))}, + } + + return expected + }, + wantErr: "linux/amd64 config Entrypoint is", }, } @@ -585,15 +661,7 @@ func TestVerifySBOMsErrors(t *testing.T) { }, "sbom-aarch64.spdx.json": {Data: sbomBytes(t, testVersion)}, }, - wantErr: "sbom-x86_64.spdx.json has no APPLICATION package at version 1.2.3-r0", - }, - { - name: "wrong versionInfo", - files: fstest.MapFS{ - "sbom-x86_64.spdx.json": {Data: sbomBytes(t, "9.9.9")}, - "sbom-aarch64.spdx.json": {Data: sbomBytes(t, testVersion)}, - }, - wantErr: "sbom-x86_64.spdx.json has no APPLICATION package at version 1.2.3-r0", + wantErr: "sbom-x86_64.spdx.json has no APPLICATION package", }, } @@ -614,14 +682,18 @@ func TestCanonicalDigests(t *testing.T) { amd64 := []byte(testAMD64Binary) arm64 := []byte(testARM64Binary) work := fstest.MapFS{ - path.Join("sources", "x86_64", "application"): {Data: amd64}, - path.Join("sources", "aarch64", "application"): {Data: arm64}, + path.Join("sources", "x86_64", testBinaryName): {Data: amd64}, + path.Join("sources", "aarch64", testBinaryName): {Data: arm64}, } - got, err := image.CanonicalDigests(work, []image.APKArch{image.ArchX8664, image.ArchAArch64}) + got, err := image.CanonicalDigests( + work, + []image.APKArch{image.ArchX8664, image.ArchAArch64}, + []string{testBinaryName}, + ) require.NoError(t, err) - assert.Equal(t, digestOf(t, amd64), got[image.ArchX8664]) - assert.Equal(t, digestOf(t, arm64), got[image.ArchAArch64]) + assert.Equal(t, digestOf(t, amd64), got[image.ArchX8664][testBinaryName]) + assert.Equal(t, digestOf(t, arm64), got[image.ArchAArch64][testBinaryName]) } func TestCanonicalDigestsErrors(t *testing.T) { @@ -631,26 +703,36 @@ func TestCanonicalDigestsErrors(t *testing.T) { name string work fs.FS arches []image.APKArch + names []string wantErr string }{ { name: "nil filesystem", arches: []image.APKArch{image.ArchX8664}, + names: []string{testBinaryName}, wantErr: "work filesystem is nil", }, { - name: "missing application", + name: "empty names", + work: fstest.MapFS{}, + arches: []image.APKArch{image.ArchX8664}, + wantErr: "canonical binary name list is empty", + }, + { + name: "missing binary", work: fstest.MapFS{}, arches: []image.APKArch{image.ArchX8664}, - wantErr: "sources/x86_64/application", + names: []string{testBinaryName}, + wantErr: "sources/x86_64/" + testBinaryName, }, { name: "non-regular entry", work: fstest.MapFS{ - "sources/x86_64/application": {Mode: fs.ModeDir}, + path.Join("sources", "x86_64", testBinaryName): {Mode: fs.ModeDir}, }, arches: []image.APKArch{image.ArchX8664}, - wantErr: "sources/x86_64/application is not a regular file", + names: []string{testBinaryName}, + wantErr: "sources/x86_64/" + testBinaryName + " is not a regular file", }, } @@ -658,7 +740,7 @@ func TestCanonicalDigestsErrors(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - _, err := image.CanonicalDigests(test.work, test.arches) + _, err := image.CanonicalDigests(test.work, test.arches, test.names) require.Error(t, err) assert.Contains(t, err.Error(), test.wantErr) }) diff --git a/internal/stage/image_inputs.go b/internal/stage/image_inputs.go index 8aff577..f15c5bb 100644 --- a/internal/stage/image_inputs.go +++ b/internal/stage/image_inputs.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "path/filepath" + "slices" "strings" "github.com/meigma/release/internal/rel" @@ -15,7 +16,7 @@ const ( // ImageInputsName is the projection filename written into the dist directory. ImageInputsName = "oci-build-inputs.json" // ImageInputsSchema is the versioned projection identifier. - ImageInputsSchema = "release.dev/oci-build-inputs/v1" + ImageInputsSchema = "release.dev/oci-build-inputs/v2" // bytesPerKiB is the number of bytes in a kibibyte. bytesPerKiB = 1024 @@ -29,8 +30,6 @@ const ( // document can be distinguished from a document that fills the bound. jsonLimitReadBytes = jsonLimitBytes + 1 - // requiredImageInputCount is the closed set of Linux platforms an image build needs. - requiredImageInputCount = 2 // platformAMD64 is the linux/amd64 projection platform. platformAMD64 = "linux/amd64" // platformARM64 is the linux/arm64 projection platform. @@ -45,7 +44,8 @@ type ImageInputs struct { Schema string `json:"schema"` // Profile is the release profile that produced the staged binaries. Profile string `json:"profile"` - // Binaries are the canonical Linux binary facts, one per required platform. + // Binaries are the canonical Linux binary facts, sorted platform-major + // then name-ascending. Binaries []ImageInputBinary `json:"binaries"` } @@ -53,7 +53,7 @@ type ImageInputs struct { type ImageInputBinary struct { // Platform is the os/architecture pair, either linux/amd64 or linux/arm64. Platform string `json:"platform"` - // Name is the binary filename, identical across platforms. + // Name is the binary filename for this platform. Name string `json:"name"` // Path is the artifact-root-relative confined path. Path string `json:"path"` @@ -62,6 +62,9 @@ type ImageInputBinary struct { } // NewImageInputs builds a projection from the profile name and staged binaries. +// +// Entries are sorted platform-major (linux/amd64, then linux/arm64), then +// name-ascending, before validation. func NewImageInputs(profile string, report Report) (ImageInputs, error) { binaries := make([]ImageInputBinary, 0, len(report.Binaries)) for _, binary := range report.Binaries { @@ -72,6 +75,7 @@ func NewImageInputs(profile string, report Report) (ImageInputs, error) { Digest: digestPrefix + binary.Digest.String(), }) } + slices.SortFunc(binaries, compareImageInputBinary) inputs := ImageInputs{ Schema: ImageInputsSchema, @@ -136,10 +140,10 @@ func DecodeImageInputs(r io.Reader) (ImageInputs, error) { // Validate reports whether i is a well-formed projection document. // // It is lexical only: it rejects a schema other than [ImageInputsSchema], an -// empty profile, a binary count other than two, a platform other than -// linux/amd64 or linux/arm64, a duplicated platform, mismatched or empty -// names, a name containing a path separator, a path that is not -// [filepath.IsLocal], and a digest that [rel.ParseDigest] rejects. +// empty profile, an empty binary list, a platform other than linux/amd64 or +// linux/arm64, a duplicate (platform, name) pair, a name present on only one +// architecture, an empty name, a name containing a path separator, a path +// that is not [filepath.IsLocal], and a digest that [rel.ParseDigest] rejects. func (i ImageInputs) Validate() error { if i.Schema != ImageInputsSchema { return fmt.Errorf("oci-build-inputs schema %q is unsupported", i.Schema) @@ -147,35 +151,34 @@ func (i ImageInputs) Validate() error { if i.Profile == "" { return errors.New("oci-build-inputs profile is empty") } - if len(i.Binaries) != requiredImageInputCount { - return fmt.Errorf("oci-build-inputs has %d binaries, want %d", len(i.Binaries), requiredImageInputCount) + if len(i.Binaries) == 0 { + return errors.New("oci-build-inputs binaries is empty") } - seen := make(map[string]int, requiredImageInputCount) - var sharedName string + seen := make(map[string]int, len(i.Binaries)) + namesByPlatform := map[string]map[string]struct{}{ + platformAMD64: {}, + platformARM64: {}, + } for index, binary := range i.Binaries { if err := validateImageInputBinary(index, binary); err != nil { return err } - if previous, exists := seen[binary.Platform]; exists { + key := binary.Platform + "\x00" + binary.Name + if previous, exists := seen[key]; exists { return fmt.Errorf( - "oci-build-inputs binaries[%d] duplicates platform %q from binaries[%d]", + "oci-build-inputs binaries[%d] duplicates platform %q name %q from binaries[%d]", index, binary.Platform, + binary.Name, previous, ) } - seen[binary.Platform] = index - if sharedName == "" { - sharedName = binary.Name - continue - } - if binary.Name != sharedName { - return fmt.Errorf("oci-build-inputs binaries have different names %q and %q", sharedName, binary.Name) - } + seen[key] = index + namesByPlatform[binary.Platform][binary.Name] = struct{}{} } - return nil + return requireMatchingImageInputNames(namesByPlatform) } // validateImageInputBinary reports lexical problems on one projection binary. @@ -204,3 +207,38 @@ func validateImageInputBinary(index int, binary ImageInputBinary) error { return nil } + +// requireMatchingImageInputNames requires every name on both architectures. +func requireMatchingImageInputNames(namesByPlatform map[string]map[string]struct{}) error { + union := make(map[string]struct{}) + for _, names := range namesByPlatform { + for name := range names { + union[name] = struct{}{} + } + } + ordered := make([]string, 0, len(union)) + for name := range union { + ordered = append(ordered, name) + } + slices.Sort(ordered) + + for _, platform := range []string{platformAMD64, platformARM64} { + have := namesByPlatform[platform] + for _, name := range ordered { + if _, ok := have[name]; !ok { + return fmt.Errorf("oci-build-inputs is missing %s binary %q", platform, name) + } + } + } + + return nil +} + +// compareImageInputBinary orders entries platform-major, then name-ascending. +func compareImageInputBinary(a, b ImageInputBinary) int { + if a.Platform != b.Platform { + return strings.Compare(a.Platform, b.Platform) + } + + return strings.Compare(a.Name, b.Name) +} diff --git a/internal/stage/image_inputs_test.go b/internal/stage/image_inputs_test.go index 74e875a..67844a5 100644 --- a/internal/stage/image_inputs_test.go +++ b/internal/stage/image_inputs_test.go @@ -62,6 +62,70 @@ func TestNewImageInputs(t *testing.T) { assert.Equal(t, validImageInputs(), got) } +func TestNewImageInputsSortsPlatformMajorThenName(t *testing.T) { + t.Parallel() + + got, err := stage.NewImageInputs("go", stage.Report{ + Binaries: []stage.Binary{ + { + Arch: "arm64", + Path: "dist/server_linux_arm64/incus-server", + RelativePath: "server_linux_arm64/incus-server", + Name: "incus-server", + Digest: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + }, + { + Arch: "amd64", + Path: "dist/server_linux_amd64/incus-server", + RelativePath: "server_linux_amd64/incus-server", + Name: "incus-server", + Digest: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }, + { + Arch: "arm64", + Path: "dist/agent_linux_arm64/incus-agent", + RelativePath: "agent_linux_arm64/incus-agent", + Name: "incus-agent", + Digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + { + Arch: "amd64", + Path: "dist/agent_linux_amd64/incus-agent", + RelativePath: "agent_linux_amd64/incus-agent", + Name: "incus-agent", + Digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + }) + require.NoError(t, err) + assert.Equal(t, []stage.ImageInputBinary{ + { + Platform: "linux/amd64", + Name: "incus-agent", + Path: "agent_linux_amd64/incus-agent", + Digest: validImageDigest, + }, + { + Platform: "linux/amd64", + Name: "incus-server", + Path: "server_linux_amd64/incus-server", + Digest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }, + { + Platform: "linux/arm64", + Name: "incus-agent", + Path: "agent_linux_arm64/incus-agent", + Digest: otherImageDigest, + }, + { + Platform: "linux/arm64", + Name: "incus-server", + Path: "server_linux_arm64/incus-server", + Digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + }, + }, got.Binaries) +} + func TestDecodeImageInputsRejectsUnknownField(t *testing.T) { t.Parallel() @@ -138,20 +202,20 @@ func TestImageInputsValidate(t *testing.T) { wantErr: `oci-build-inputs schema "release.dev/oci-build-inputs/v0" is unsupported`, }, { - name: "wrong binary count", + name: "empty binaries", mutate: func(inputs stage.ImageInputs) stage.ImageInputs { - inputs.Binaries = inputs.Binaries[:1] + inputs.Binaries = nil return inputs }, - wantErr: "oci-build-inputs has 1 binaries, want 2", + wantErr: "oci-build-inputs binaries is empty", }, { - name: "duplicate platform", + name: "duplicate platform and name", mutate: func(inputs stage.ImageInputs) stage.ImageInputs { inputs.Binaries[1].Platform = "linux/amd64" return inputs }, - wantErr: `oci-build-inputs binaries[1] duplicates platform "linux/amd64" from binaries[0]`, + wantErr: `oci-build-inputs binaries[1] duplicates platform "linux/amd64" name "release-cli" from binaries[0]`, }, { name: "unknown platform", @@ -162,12 +226,12 @@ func TestImageInputsValidate(t *testing.T) { wantErr: `oci-build-inputs binaries[1] platform "linux/s390x" is not linux/amd64 or linux/arm64`, }, { - name: "mismatched names", + name: "asymmetric name set", mutate: func(inputs stage.ImageInputs) stage.ImageInputs { inputs.Binaries[1].Name = "other" return inputs }, - wantErr: `oci-build-inputs binaries have different names "release-cli" and "other"`, + wantErr: `oci-build-inputs is missing linux/amd64 binary "other"`, }, { name: "absolute path", diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 0f5a259..4c8ecfb 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -14,8 +14,8 @@ import ( // Stage verifies a Go profile dist directory whose basename is root. // // It parses checksums.txt, streams every claimed payload through SHA-256, -// requires a nonempty regular checksums.txt.sigstore.json, selects exactly -// one linux/amd64 and one linux/arm64 Binary from artifacts.json, and +// requires a nonempty regular checksums.txt.sigstore.json, selects every +// linux/{amd64,arm64} Binary from artifacts.json, and // confirms each selected path is a confined regular executable. Each // selected binary is then streamed through SHA-256 so the report can // carry its digest and filename. A nil filesystem is rejected. diff --git a/melange.yaml b/melange.yaml index 244e3d7..e464f4e 100644 --- a/melange.yaml +++ b/melange.yaml @@ -23,4 +23,4 @@ environment: pipeline: - runs: | - install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/release-cli" + install -Dm755 -o 0 -g 0 release-cli "${{targets.destdir}}/usr/bin/release-cli"