diff --git a/.gitignore b/.gitignore index 37af592..efbe5b9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .moon/cache/ bin/ dist/ +/release-cli diff --git a/.mockery.yml b/.mockery.yml index 788f266..d3a81d9 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -30,3 +30,10 @@ packages: StateReader: config: filename: state_reader.go + ContentPusher: + config: + filename: content_pusher.go + Signer: + config: + dir: internal/adapter/cosign/mocks + filename: signer.go diff --git a/cmd/release-cli/main.go b/cmd/release-cli/main.go index 2b963b5..ad61a44 100644 --- a/cmd/release-cli/main.go +++ b/cmd/release-cli/main.go @@ -7,6 +7,7 @@ import ( "os/signal" "syscall" + "github.com/meigma/release/internal/adapter/cosign" "github.com/meigma/release/internal/adapter/ghact" "github.com/meigma/release/internal/adapter/reg" "github.com/meigma/release/internal/cli" @@ -37,12 +38,16 @@ func run() int { NewArtifactMeta: func(token string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { return ghact.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) }, - NewStateReader: func(credentials cli.RegistryCredentials) (puboci.StateReader, error) { - return reg.New(reg.Options{ - Credentials: reg.Credentials{ - Username: credentials.Username, - Password: credentials.Password, - }, + NewStateReader: func(config cli.RegistryConfig) (puboci.StateReader, error) { + return newRegistryClient(config), nil + }, + NewContentPusher: func(config cli.RegistryConfig) (puboci.ContentPusher, error) { + return newRegistryClient(config), nil + }, + NewSigner: func(path string) (puboci.Signer, error) { + return cosign.New(cosign.Options{ + Path: path, + Stderr: os.Stderr, }), nil }, Build: cli.BuildInfo{ @@ -58,3 +63,14 @@ func run() int { return 0 } + +// newRegistryClient constructs the shared registry adapter from resolved config. +func newRegistryClient(config cli.RegistryConfig) *reg.Client { + return reg.New(reg.Options{ + Credentials: reg.Credentials{ + Username: config.Credentials.Username, + Password: config.Credentials.Password, + }, + PlainHTTP: config.PlainHTTP, + }) +} diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md index b60fdc3..02d8770 100644 --- a/docs/reference/oci-image-contract.md +++ b/docs/reference/oci-image-contract.md @@ -207,6 +207,10 @@ The exact tag must resolve to the builder's expected OCI index digest after publ A direct `plan tags` invocation has no repository-wide concurrency lock. Two concurrent planners outside the publisher workflow can observe the same registry state and plan conflicting channel moves. Direct use therefore requires a single writer by convention. +`release-cli publish oci prepare` reproduces the publisher's digest-addressed publication and recursive Cosign-signing steps. It can be exercised independently for verification and the upcoming two-phase publication. In this release, the reusable publisher workflow remains authoritative and does not call `publish oci prepare`. Its existing `actions/github-script` steps continue to perform publication, signing, attestation, and tagging. + +Trust metadata still precedes every public tag. `publish oci prepare` never creates or moves a tag, and the authoritative workflow applies tags only after signing and attestation complete. + Digest-pinned references are the durable consumer interface: ```text diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index 68b10f1..294837f 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -1,18 +1,21 @@ # `release-cli` contract reference -`release-cli` validates and reports release data for the reusable workflows. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI. +`release-cli` validates release data, reports machine-readable results, and prepares digest-addressed OCI publication. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI. ## Commands | Command | Purpose | | --- | --- | | `release-cli stage --profile go --dist PATH [--json]` | Validate the staged Go release files under `PATH`. | -| `release-cli plan tags [--image IMAGE] [--version VERSION] --digest DIGEST [--json]` | Inspect the immutable exact tag and moving channel tags for an OCI release. | +| `release-cli plan tags [--image IMAGE] [--version VERSION] --digest DIGEST [--plain-http] [--json]` | Inspect the immutable exact tag and moving channel tags for an OCI release. | +| `release-cli publish oci prepare --layout PATH [--image IMAGE] [--version VERSION] --digest DIGEST [--dry-run] [--plain-http] [--json]` | Validate and prepare a digest-addressed OCI image publication and recursive signature. | | `release-cli verify handoff --artifact-id --digest [--json]` | Verify an Actions artifact's GitHub API metadata before download. | | `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. | `--dist` is required for `stage`. The only accepted profile is `go`. `verify handoff` requires artifact ID and digest values. Supply them with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable. +Boolean `RELEASE_*` environment variables must contain a value accepted by Go's `strconv.ParseBool`: `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, or `False`. Any other value is invalid configuration and exits with code `2`. + The artifact ID must be a positive decimal safe integer. The digest must be a 64-digit hexadecimal SHA-256 value with or without the `sha256:` prefix. Digest hex is case-insensitive and is normalized to lowercase with the prefix. ## JSON output @@ -26,7 +29,7 @@ When option and argument parsing succeeds and `--json` is requested, stdout cont | Field | Value | | --- | --- | | `schema` | Always `release.dev/result/v1`. | -| `command` | The command path, such as `plan tags`, `stage`, `verify handoff`, or `version`. | +| `command` | The command path, such as `plan tags`, `publish oci prepare`, `stage`, `verify handoff`, or `version`. | | `ok` | `true` when the command succeeds; otherwise `false`. | | `result` | The command-specific result object. | @@ -69,6 +72,69 @@ For example, this result plans to apply the exact and minor tags, retain the maj } ``` +For `publish oci prepare --json`, `command` is exactly `publish oci prepare`. The `result` object has schema `release.dev/oci-prepare/v1` and contains these fields: + +| Field | JSON type | Value | +| --- | --- | --- | +| `schema` | string | Always `release.dev/oci-prepare/v1`. | +| `authoritative` | boolean | `true` after a non-dry-run preparation completes; `false` for `--dry-run`. A non-authoritative result is not usable for publication. | +| `image` | string | OCI image name prepared by the command. | +| `version` | string | Candidate stable release version. | +| `index_digest` | string | OCI index digest, normalized to lowercase with the `sha256:` prefix. | +| `platforms` | array of objects | Platform manifests in the order recorded by `index.json`. | +| `platforms[].platform` | string | Platform in `OS/architecture` form, such as `linux/amd64`. | +| `platforms[].digest` | string | Digest of the platform manifest. | +| `observed` | array of objects | Registry observations ordered by scope: exact, minor, major, then latest. | +| `observed[].tag` | string | Exact or channel tag that was observed. | +| `observed[].scope` | string | Tag scope: `exact`, `minor`, `major`, or `latest`. | +| `observed[].present` | boolean | Whether the tag was present in the registry. | +| `observed[].digest` | string | Digest resolved from a present tag. This field is omitted for an absent tag. | +| `observed[].version` | string | Stable version read from the current manifest annotation. This field is omitted when no annotation was read. | + +For example, a successful non-dry-run preparation writes this standard envelope: + +```json +{ + "schema": "release.dev/result/v1", + "command": "publish oci prepare", + "ok": true, + "result": { + "schema": "release.dev/oci-prepare/v1", + "authoritative": true, + "image": "ghcr.io/owner/repo", + "version": "1.2.3", + "index_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "platforms": [ + { + "platform": "linux/amd64", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "platform": "linux/arm64", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ], + "observed": [ + {"tag": "1.2.3", "scope": "exact", "present": false}, + {"tag": "1.2", "scope": "minor", "present": false}, + { + "tag": "1", + "scope": "major", + "present": true, + "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "version": "1.1.9" + }, + { + "tag": "latest", + "scope": "latest", + "present": true, + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + } +} +``` + The `version --json` result contains exactly these fields: | Field | JSON type | Value | @@ -109,7 +175,7 @@ parse or dispatch failures skip the envelope. These include an unknown command or flag, an invalid flag value, or the wrong number of arguments. The usage error goes to stderr and the process exits with code `2`. -Without `--json`, a successful `plan tags`, `stage`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. +Without `--json`, a successful `plan tags`, `publish oci prepare`, `stage`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. ## Exit codes @@ -130,10 +196,13 @@ No other exit code is defined; in particular, code `3` has no meaning. An exit c | Image | `--image` | `RELEASE_IMAGE` | `ghcr.io//`, lowercased from `GITHUB_REPOSITORY`. | | Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | | Digest | `--digest` | `RELEASE_DIGEST` | None. A digest is required. | +| Plain HTTP | `--plain-http` | None. The option is flag-only. | Disabled. | | JSON output | `--json` | `RELEASE_JSON` | Disabled. | An explicitly set flag takes precedence over its environment variable. The derived default applies only when the corresponding flag and release environment variable are absent. The image must have the lowercase form `host/path[/path...]` without a tag or digest. The digest must have the `sha256:` prefix followed by 64 hexadecimal digits. +`--plain-http` permits an HTTP registry connection for local-registry testing only. The command refuses this flag unless the image host is `127.0.0.1`, `::1`, or `localhost`, optionally with a port. Any other host is invalid configuration and exits with code `2`. + The command resolves registry credentials in this order: | Credential | Resolution | @@ -189,6 +258,49 @@ For a valid channel annotation on a different digest, the command compares the c The publisher workflow serializes tag planning and application with a repository-wide concurrency group. A direct `plan tags` invocation outside that workflow has no cross-run lock. Two concurrent planners can observe the same registry state and plan conflicting channel moves. Direct use therefore requires a single writer by convention. +## OCI digest preparation + +`release-cli publish oci prepare` validates and prepares one digest-addressed OCI layout for publication. + +| Value | Flag | Environment variable | Default | +| --- | --- | --- | --- | +| Layout directory | `--layout` | `RELEASE_LAYOUT` | None. A path is required. | +| Image | `--image` | `RELEASE_IMAGE` | `ghcr.io//`, lowercased from `GITHUB_REPOSITORY`. | +| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | +| Expected index digest | `--digest` | `RELEASE_DIGEST` | None. A digest is required. | +| Dry run | `--dry-run` | `RELEASE_DRY_RUN` | Disabled. | +| Plain HTTP | `--plain-http` | None. The option is flag-only. | Disabled. | +| JSON output | `--json` | `RELEASE_JSON` | Disabled. | + +`--layout` identifies the extracted `oci-image/layout` directory. An explicitly set flag takes precedence over its environment variable. The derived image or version default applies only when the corresponding flag and release environment variable are absent. Image, version, and digest validation is the same as for `plan tags`. + +`--plain-http` permits an HTTP registry connection for local-registry testing only. The command refuses this flag unless the image host is `127.0.0.1`, `::1`, or `localhost`, optionally with a port. Any other host is invalid configuration and exits with code `2`. Never use plain HTTP for a real publication. + +Registry credentials use the same token and username resolution as `plan tags`. The command keeps these credentials in memory and does not write a Docker configuration file. + +The command performs these operations in order: + +1. Read and validate the OCI layout. +2. Compute the digest of the exact `index.json` bytes and require it to equal the expected `--digest`. +3. Collect fresh registry state and plan the exact and channel tags. An immutable exact-tag conflict stops the command before any registry write. +4. Push every unique config and layer blob, each platform manifest, and the index by digest. +5. Verify that the index and each platform manifest resolve by their expected digest. +6. Sign `image@` recursively with Cosign. + +The command never creates or moves a tag. + +With `--dry-run`, the command performs layout validation, digest verification, fresh registry-state collection, and tag planning only. It makes zero registry writes and does not invoke Cosign. The result has `"authoritative": false`; a non-authoritative result is not usable for publication. + +The command invokes a `cosign` binary resolved from `PATH`. Set `RELEASE_COSIGN_PATH` to override the binary path. Its signing invocation is: + +```text +cosign sign --yes --recursive @ +``` + +Keyless signing requires the ambient OIDC credentials supplied by the workflow. + +The command exists for verification and the upcoming two-phase publication. In this release, the reusable publisher workflow still performs publication, signing, attestation, and tagging through its existing `actions/github-script` steps. Those workflow steps remain authoritative, and the workflow does not call `publish oci prepare`. + ## Actions artifact handoff `verify handoff` reads the artifact metadata from the GitHub Actions API before any artifact download. It validates all of these conditions: diff --git a/internal/adapter/cosign/doc.go b/internal/adapter/cosign/doc.go new file mode 100644 index 0000000..145f9ba --- /dev/null +++ b/internal/adapter/cosign/doc.go @@ -0,0 +1,8 @@ +// Package cosign implements [puboci.Signer] by invoking the pinned cosign binary. +// +// [New] builds a signer that shells out to `cosign sign --yes --recursive` +// against image@digest. Signing is keyless and recursive: the index and every +// referenced platform manifest are signed. The adapter performs no registry +// reasoning of its own. Keyless signing uses the ambient OIDC environment; +// this package never reads, stores, or logs a key or token. +package cosign diff --git a/internal/adapter/cosign/mocks/doc.go b/internal/adapter/cosign/mocks/doc.go new file mode 100644 index 0000000..023e575 --- /dev/null +++ b/internal/adapter/cosign/mocks/doc.go @@ -0,0 +1,5 @@ +// Package mocks contains Mockery-generated doubles for [puboci.Signer]. +// +// Generated files are produced by `mockery` from .mockery.yml. Do not edit +// them by hand. +package mocks diff --git a/internal/adapter/cosign/mocks/signer.go b/internal/adapter/cosign/mocks/signer.go new file mode 100644 index 0000000..eae3e3b --- /dev/null +++ b/internal/adapter/cosign/mocks/signer.go @@ -0,0 +1,97 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + + "github.com/meigma/release/internal/stage/puboci" +) + +// NewMockSigner creates a new instance of MockSigner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSigner(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSigner { + mock := &MockSigner{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockSigner is an autogenerated mock type for the Signer type +type MockSigner struct { + mock.Mock +} + +type MockSigner_Expecter struct { + mock *mock.Mock +} + +func (_m *MockSigner) EXPECT() *MockSigner_Expecter { + return &MockSigner_Expecter{mock: &_m.Mock} +} + +// SignRecursive provides a mock function for the type MockSigner +func (_mock *MockSigner) SignRecursive(ctx context.Context, ref puboci.DigestRef) error { + ret := _mock.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for SignRecursive") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.DigestRef) error); ok { + r0 = returnFunc(ctx, ref) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSigner_SignRecursive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignRecursive' +type MockSigner_SignRecursive_Call struct { + *mock.Call +} + +// SignRecursive is a helper method to define mock.On call +// - ctx context.Context +// - ref puboci.DigestRef +func (_e *MockSigner_Expecter) SignRecursive(ctx any, ref any) *MockSigner_SignRecursive_Call { + return &MockSigner_SignRecursive_Call{Call: _e.mock.On("SignRecursive", ctx, ref)} +} + +func (_c *MockSigner_SignRecursive_Call) Run(run func(ctx context.Context, ref puboci.DigestRef)) *MockSigner_SignRecursive_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 puboci.DigestRef + if args[1] != nil { + arg1 = args[1].(puboci.DigestRef) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockSigner_SignRecursive_Call) Return(err error) *MockSigner_SignRecursive_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSigner_SignRecursive_Call) RunAndReturn(run func(ctx context.Context, ref puboci.DigestRef) error) *MockSigner_SignRecursive_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/cosign/signer.go b/internal/adapter/cosign/signer.go new file mode 100644 index 0000000..3b3b057 --- /dev/null +++ b/internal/adapter/cosign/signer.go @@ -0,0 +1,197 @@ +package cosign + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "time" + + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // defaultBinary is the PATH name used when [Options.Path] is empty. + defaultBinary = "cosign" + // bytesPerKiB is the number of bytes in a kibibyte. + bytesPerKiB = 1024 + // stderrTailKiB is the stderr tail retained in a nonzero-exit error. + stderrTailKiB = 4 + // stderrTailLimit is the maximum number of trailing stderr bytes + // included in a nonzero-exit error. + stderrTailLimit = stderrTailKiB * bytesPerKiB + // waitDelay is how long [exec.Cmd] waits for leaked child I/O after + // the process exits or the context is canceled. + // + // Stderr is a tail buffer, not an [*os.File], so [os/exec] copies + // through a pipe and [exec.Cmd.Wait] blocks until EOF. + // [exec.CommandContext] kills only the direct child; a grandchild + // holding the write end would hang [Signer.SignRecursive] forever + // without this bound. + waitDelay = 5 * time.Second +) + +// Options configures a [Signer]. +type Options struct { + // Path is the cosign executable. An empty path resolves "cosign" from + // PATH with [exec.LookPath] when signing. + Path string + + // Environ is the child process environment. A nil value inherits + // [os.Environ]. The slice is used as-is and is never logged. + Environ []string + + // Stderr receives cosign diagnostics while the process runs. A nil + // value discards them. A nonzero exit still captures a bounded tail + // for the returned error. + Stderr io.Writer +} + +// Signer invokes the cosign CLI to attach signatures. +// +// It implements [puboci.Signer]. It performs no registry reads or tag +// mutation. Keyless credentials come from the process environment. +type Signer struct { + // path is the cosign binary. Empty resolves [defaultBinary] from PATH + // at sign time. + path string + + // environ is the process environment. Nil inherits [os.Environ]. + environ []string + + // stderr receives cosign diagnostics. Nil discards them. + stderr io.Writer +} + +// New constructs a [Signer] from options. +// +// Path resolution is deferred until [Signer.SignRecursive] so a missing +// binary is reported when signing, not at construction. +func New(options Options) *Signer { + return &Signer{ + path: options.Path, + environ: options.Environ, + stderr: options.Stderr, + } +} + +// SignRecursive implements [puboci.Signer]. +// +// It runs `cosign sign --yes --recursive` against ref as an explicit +// argument slice through [exec.CommandContext]. A nil context, a nil +// receiver, or a zero-value ref is rejected before any process starts. +// A nonzero exit returns an error that names the exit code and a tail of +// stderr limited to [stderrTailLimit] bytes. +func (s *Signer) SignRecursive(ctx context.Context, ref puboci.DigestRef) error { + if ctx == nil { + return errors.New("context is nil") + } + if s == nil { + return errors.New("cosign signer is nil") + } + if ref.Image == "" || ref.Digest == "" { + return errors.New("digest reference is empty") + } + + path, err := resolveBinary(s.path) + if err != nil { + return err + } + + // Path is a resolved executable. The argument list is a fixed slice, + // never a shell string. + cmd := exec.CommandContext(ctx, path, "sign", "--yes", "--recursive", ref.String()) + if s.environ != nil { + cmd.Env = s.environ + } + cmd.Stdout = io.Discard + + tail := newTailBuffer(stderrTailLimit) + stderr := io.Writer(tail) + if s.stderr != nil { + stderr = io.MultiWriter(s.stderr, tail) + } + cmd.Stderr = stderr + // WaitDelay unblocks Wait if a grandchild still holds the stderr pipe + // after CommandContext kills only the direct child. + cmd.WaitDelay = waitDelay + if err := cmd.Run(); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("sign %s: %w", ref, ctxErr) + } + + return signError(ref, tail, err) + } + + return nil +} + +// resolveBinary returns the cosign executable path. +// +// An empty path looks up [defaultBinary] on PATH. +func resolveBinary(path string) (string, error) { + name := path + if name == "" { + name = defaultBinary + } + + resolved, err := exec.LookPath(name) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", name, err) + } + + return resolved, nil +} + +// signError formats a cosign process failure. +func signError(ref puboci.DigestRef, tail *tailBuffer, err error) error { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return fmt.Errorf("cosign sign %s: %w", ref, err) + } + if tail.String() == "" { + return fmt.Errorf("cosign sign %s: exit %d", ref, exitErr.ExitCode()) + } + + return fmt.Errorf("cosign sign %s: exit %d: %s", ref, exitErr.ExitCode(), tail.String()) +} + +// tailBuffer keeps the last limit bytes written to it. +type tailBuffer struct { + // limit is the maximum number of bytes retained. + limit int + + // buf holds the retained tail. + buf []byte +} + +// newTailBuffer returns a writer that retains the last limit bytes. +func newTailBuffer(limit int) *tailBuffer { + return &tailBuffer{limit: limit} +} + +// Write appends p, discarding older bytes when the tail exceeds the limit. +func (b *tailBuffer) Write(p []byte) (int, error) { + if b.limit <= 0 { + return len(p), nil + } + if len(p) >= b.limit { + b.buf = append(b.buf[:0], p[len(p)-b.limit:]...) + + return len(p), nil + } + + need := len(b.buf) + len(p) - b.limit + if need > 0 { + b.buf = b.buf[need:] + } + b.buf = append(b.buf, p...) + + return len(p), nil +} + +// String returns the retained tail as a string. +func (b *tailBuffer) String() string { + return string(b.buf) +} diff --git a/internal/adapter/cosign/signer_test.go b/internal/adapter/cosign/signer_test.go new file mode 100644 index 0000000..7727504 --- /dev/null +++ b/internal/adapter/cosign/signer_test.go @@ -0,0 +1,357 @@ +package cosign + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testImage = "ghcr.io/owner/repo" + // cancelWait is how long the cancel test waits for the fake to start + // and then for SignRecursive to return. + cancelWait = 2 * time.Second + // cancelPoll is the interval used while waiting for the fake to start. + cancelPoll = 10 * time.Millisecond + fakeCosignScript = `#!/bin/sh +if [ -n "${COSIGN_STARTED:-}" ]; then + : > "$COSIGN_STARTED" +fi +if [ -n "${COSIGN_RECORD:-}" ]; then + printf '%s\n' "$@" > "$COSIGN_RECORD" +fi +if [ -n "${COSIGN_STDERR_FILE:-}" ]; then + cat "$COSIGN_STDERR_FILE" >&2 +elif [ -n "${COSIGN_STDERR:-}" ]; then + printf '%s' "$COSIGN_STDERR" >&2 +fi +if [ -n "${COSIGN_ORPHAN:-}" ]; then + sleep "${COSIGN_SLEEP:-30}" & + wait + exit "${COSIGN_EXIT:-0}" +fi +if [ -n "${COSIGN_SLEEP:-}" ]; then + exec sleep "$COSIGN_SLEEP" +fi +exit "${COSIGN_EXIT:-0}" +` +) + +func TestSignRecursiveInvokesCosign(t *testing.T) { + skipWindows(t) + t.Parallel() + + dir := t.TempDir() + record := filepath.Join(dir, "args") + path := writeFake(t, dir) + + err := New(Options{ + Path: path, + Environ: fakeEnviron(t, "COSIGN_RECORD="+record), + }).SignRecursive(context.Background(), mustRef(t)) + require.NoError(t, err) + assertRecordedArgv(t, record) +} + +func TestSignRecursiveNonzeroExitIncludesCodeAndStderr(t *testing.T) { + skipWindows(t) + t.Parallel() + + dir := t.TempDir() + path := writeFake(t, dir) + + err := New(Options{ + Path: path, + Environ: fakeEnviron(t, + "COSIGN_EXIT=3", + "COSIGN_STDERR=denied by fulcio", + ), + }).SignRecursive(context.Background(), mustRef(t)) + require.Error(t, err) + assert.Contains(t, err.Error(), "exit 3") + assert.Contains(t, err.Error(), "denied by fulcio") +} + +func TestSignRecursiveTruncatesLargeStderr(t *testing.T) { + skipWindows(t) + t.Parallel() + + dir := t.TempDir() + path := writeFake(t, dir) + head := bytes.Repeat([]byte("H"), stderrTailLimit) + tail := bytes.Repeat([]byte("T"), stderrTailLimit) + stderrFile := filepath.Join(dir, "stderr.txt") + require.NoError(t, os.WriteFile(stderrFile, append(head, tail...), 0o600)) + + err := New(Options{ + Path: path, + Environ: fakeEnviron(t, + "COSIGN_EXIT=1", + "COSIGN_STDERR_FILE="+stderrFile, + ), + }).SignRecursive(context.Background(), mustRef(t)) + require.Error(t, err) + assert.Contains(t, err.Error(), "exit 1") + assert.NotContains(t, err.Error(), string(head)) + assert.Contains(t, err.Error(), string(tail)) + assert.LessOrEqual(t, strings.Count(err.Error(), "T"), stderrTailLimit) +} + +func TestSignRecursiveResolvesEmptyPath(t *testing.T) { + skipWindows(t) + + t.Run("finds cosign on PATH", func(t *testing.T) { + dir := t.TempDir() + record := filepath.Join(dir, "args") + writeFake(t, dir) + t.Setenv("PATH", dir) + t.Setenv("COSIGN_RECORD", record) + + err := New(Options{}).SignRecursive(context.Background(), mustRef(t)) + require.NoError(t, err) + assertRecordedArgv(t, record) + }) + + t.Run("missing cosign is a clear error", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := New(Options{}).SignRecursive(context.Background(), mustRef(t)) + require.Error(t, err) + assert.Contains(t, err.Error(), "cosign") + assert.Contains(t, err.Error(), "PATH") + }) +} + +func TestSignRecursiveCanceledContextReturnsPromptly(t *testing.T) { + skipWindows(t) + t.Parallel() + + dir := t.TempDir() + started := filepath.Join(dir, "started") + err := cancelAfterStart( + t, + writeFake(t, dir), + fakeEnviron(t, "COSIGN_STARTED="+started, "COSIGN_SLEEP=30"), + started, + cancelWait, + ) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +func TestSignRecursiveCanceledContextUnblocksOrphanGrandchild(t *testing.T) { + skipWindows(t) + t.Parallel() + + dir := t.TempDir() + started := filepath.Join(dir, "started") + err := cancelAfterStart( + t, + writeFake(t, dir), + fakeEnviron(t, "COSIGN_STARTED="+started, "COSIGN_ORPHAN=1", "COSIGN_SLEEP=30"), + started, + waitDelay+cancelWait, + ) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +func TestSignRecursiveWritesStderrSink(t *testing.T) { + skipWindows(t) + t.Parallel() + + dir := t.TempDir() + path := writeFake(t, dir) + var sink bytes.Buffer + + err := New(Options{ + Path: path, + Environ: fakeEnviron(t, "COSIGN_STDERR=diagnostic line"), + Stderr: &sink, + }).SignRecursive(context.Background(), mustRef(t)) + require.NoError(t, err) + assert.Equal(t, "diagnostic line", sink.String()) +} + +// The subtests share one marker file, so this test does not run in parallel. +func TestSignRecursiveRejectsBeforeStart(t *testing.T) { + skipWindows(t) + + dir := t.TempDir() + started := filepath.Join(dir, "started") + path := writeFake(t, dir) + environ := fakeEnviron(t, "COSIGN_STARTED="+started) + signer := New(Options{Path: path, Environ: environ}) + + tests := []struct { + name string + ctx context.Context + signer *Signer + ref puboci.DigestRef + want string + }{ + { + name: "nil context", + ctx: nil, + signer: signer, + ref: mustRef(t), + want: "context is nil", + }, + { + name: "nil signer", + ctx: context.Background(), + signer: nil, + ref: mustRef(t), + want: "cosign signer is nil", + }, + { + name: "zero reference", + ctx: context.Background(), + signer: signer, + ref: puboci.DigestRef{}, + want: "digest reference is empty", + }, + { + name: "empty image", + ctx: context.Background(), + signer: signer, + ref: puboci.DigestRef{Digest: mustDigest(t)}, + want: "digest reference is empty", + }, + { + name: "empty digest", + ctx: context.Background(), + signer: signer, + ref: puboci.DigestRef{Image: mustImage(t)}, + want: "digest reference is empty", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, os.RemoveAll(started)) + + err := test.signer.SignRecursive(test.ctx, test.ref) + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + assert.NoFileExists(t, started) + }) + } +} + +// skipWindows skips POSIX shell fixtures on Windows. +func skipWindows(t *testing.T) { + t.Helper() + + if runtime.GOOS == "windows" { + t.Skip("posix shell fixture") + } +} + +// cancelAfterStart runs SignRecursive, cancels after the fake starts, and +// returns the call error. It fails the test if the call exceeds bound. +func cancelAfterStart( + t *testing.T, + path string, + environ []string, + started string, + bound time.Duration, +) error { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ref := mustRef(t) + done := make(chan error, 1) + go func() { + done <- New(Options{Path: path, Environ: environ}).SignRecursive(ctx, ref) + }() + + require.Eventually(t, func() bool { + _, err := os.Stat(started) + + return err == nil + }, bound, cancelPoll) + cancel() + + select { + case err := <-done: + return err + case <-time.After(bound): + t.Fatalf("SignRecursive did not return within %s after cancel", bound) + } + + return nil +} + +// writeFake installs an executable fake cosign script in dir. +func writeFake(t *testing.T, dir string) string { + t.Helper() + + path := filepath.Join(dir, defaultBinary) + require.NoError(t, os.WriteFile(path, []byte(fakeCosignScript), 0o755)) + require.NoError(t, os.Chmod(path, 0o755)) + + return path +} + +// fakeEnviron copies the process environment and appends extra KEY=value pairs. +func fakeEnviron(t *testing.T, extra ...string) []string { + t.Helper() + + return append(append([]string{}, os.Environ()...), extra...) +} + +// assertRecordedArgv requires the fake to have recorded the contract argv. +func assertRecordedArgv(t *testing.T, record string) { + t.Helper() + + body, err := os.ReadFile(record) + require.NoError(t, err) + got := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") + assert.Equal(t, []string{ + "sign", + "--yes", + "--recursive", + testImage + "@" + testDigest, + }, got) +} + +// mustRef returns the fixture digest reference. +func mustRef(t *testing.T) puboci.DigestRef { + t.Helper() + + return puboci.DigestRef{Image: mustImage(t), Digest: mustDigest(t)} +} + +// mustImage parses the fixture image. +func mustImage(t *testing.T) puboci.Image { + t.Helper() + + image, err := puboci.ParseImage(testImage) + require.NoError(t, err) + + return image +} + +// mustDigest parses the fixture digest. +func mustDigest(t *testing.T) rel.Digest { + t.Helper() + + digest, err := rel.ParseDigest(testDigest) + require.NoError(t, err) + + return digest +} diff --git a/internal/adapter/reg/client.go b/internal/adapter/reg/client.go index 5b3f7b3..f1baacd 100644 --- a/internal/adapter/reg/client.go +++ b/internal/adapter/reg/client.go @@ -2,6 +2,7 @@ package reg import ( "context" + "errors" "fmt" "net/http" @@ -15,7 +16,7 @@ import ( // Credentials is a registry username and password. // -// A zero value is an anonymous read. Password is a [rel.Secret] so token +// A zero value is anonymous. Password is a [rel.Secret] so token // text is not printed, logged, or encoded. type Credentials struct { // Username is the registry user. An empty username with a password is @@ -27,9 +28,9 @@ type Credentials struct { Password rel.Secret } -// Options configures a read-only registry [Client]. +// Options configures a registry [Client]. type Options struct { - // Credentials authenticates registry reads. The zero value is anonymous. + // Credentials authenticates registry requests. The zero value is anonymous. Credentials Credentials // PlainHTTP forces HTTP instead of HTTPS. Tests use this against a @@ -42,11 +43,11 @@ type Options struct { HTTPClient *http.Client } -// Client reads tag state from a GHCR-compatible registry. +// Client reads tag state and pushes digest-addressed content. // -// It implements [puboci.StateReader]. It never pushes, tags, or deletes. -// Credential material is captured inside the auth client's closure and is -// not stored on this value. +// It implements [puboci.StateReader] and [puboci.ContentPusher]. It never +// creates, moves, or deletes a tag. Credential material is captured inside +// the auth client's closure and is not stored on this value. type Client struct { // auth is the shared oras auth client. auth *auth.Client @@ -91,9 +92,21 @@ func New(options Options) *Client { } } -// repository builds a remote repository client for ref. -func (c *Client) repository(ref puboci.Reference) (*remote.Repository, error) { - repo, err := remote.NewRepository(ref.Image.String()) +// requireReady rejects a nil context or an uninitialized client. +func (c *Client) requireReady(ctx context.Context) error { + if ctx == nil { + return errors.New("context is nil") + } + if c == nil || c.auth == nil { + return errors.New("registry client is nil") + } + + return nil +} + +// repository builds a remote repository client for image. +func (c *Client) repository(image puboci.Image) (*remote.Repository, error) { + repo, err := remote.NewRepository(image.String()) if err != nil { return nil, fmt.Errorf("parse repository: %w", err) } diff --git a/internal/adapter/reg/content.go b/internal/adapter/reg/content.go new file mode 100644 index 0000000..9fc5813 --- /dev/null +++ b/internal/adapter/reg/content.go @@ -0,0 +1,158 @@ +package reg + +import ( + "context" + "errors" + "fmt" + "io" + + godigest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry/remote" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +// PushBlob implements [puboci.ContentPusher]. +// +// Content is streamed to the registry and is never buffered. An already-present +// blob is success so a retry of a partial publication can converge. +func (c *Client) PushBlob( + ctx context.Context, + image puboci.Image, + descriptor puboci.Descriptor, + content io.Reader, +) error { + if err := c.requireReady(ctx); err != nil { + return err + } + + return c.pushContent(ctx, image, descriptor, content, "blob", pushBlob) +} + +// PushManifest implements [puboci.ContentPusher]. +// +// The registry stores content under descriptor.MediaType. An already-present +// manifest is success. Unlike [Client.PushBlob], the authenticated oras +// manifest path may buffer the document in memory before the request. +func (c *Client) PushManifest( + ctx context.Context, + image puboci.Image, + descriptor puboci.Descriptor, + content io.Reader, +) error { + if err := c.requireReady(ctx); err != nil { + return err + } + + return c.pushContent(ctx, image, descriptor, content, "manifest", pushManifest) +} + +// Verify implements [puboci.ContentPusher]. +// +// Missing content wraps [puboci.ErrTagAbsent]. A different resolved digest is +// a verification failure and is not classified as absent. +func (c *Client) Verify(ctx context.Context, ref puboci.DigestRef) error { + if err := c.requireReady(ctx); err != nil { + return err + } + + repo, err := c.repository(ref.Image) + if err != nil { + return fmt.Errorf("verify %s: %w", ref.Digest, err) + } + + desc, err := repo.Resolve(ctx, ref.Digest.String()) + if err != nil { + return fmt.Errorf("verify %s: %w", ref.Digest, classify(err)) + } + + got, err := rel.ParseDigest(desc.Digest.String()) + if err != nil { + return fmt.Errorf("verify %s: registry digest: %w", ref.Digest, err) + } + if got != ref.Digest { + return fmt.Errorf("verify %s: resolved %s", ref.Digest, got) + } + + return nil +} + +// readerOnly hides an [io.Closer] behind a plain [io.Reader]. +// +// oras hands the content reader to net/http as the request body, and the +// HTTP transport always closes a request body it is given. Content readers +// belong to the caller, so this wrapper keeps the transport from closing a +// file the engine still owns. +type readerOnly struct { + // Reader is the caller-owned content stream. + io.Reader +} + +// pushContent streams content to the registry under descriptor. +func (c *Client) pushContent( + ctx context.Context, + image puboci.Image, + descriptor puboci.Descriptor, + content io.Reader, + kind string, + push func(context.Context, *remote.Repository, ocispec.Descriptor, io.Reader) error, +) error { + if content == nil { + return fmt.Errorf("push %s %s: content is nil", kind, descriptor.Digest) + } + if err := descriptor.Validate(); err != nil { + return fmt.Errorf("push %s %s: %w", kind, descriptor.Digest, err) + } + + repo, err := c.repository(image) + if err != nil { + return fmt.Errorf("push %s %s: %w", kind, descriptor.Digest, err) + } + + err = push(ctx, repo, ociDescriptor(descriptor), readerOnly{Reader: content}) + if isAlreadyPresent(err) { + return nil + } + if err != nil { + return fmt.Errorf("push %s %s: %w", kind, descriptor.Digest, classify(err)) + } + + return nil +} + +// pushBlob uploads one blob through oras. +func pushBlob( + ctx context.Context, + repo *remote.Repository, + descriptor ocispec.Descriptor, + content io.Reader, +) error { + return repo.Blobs().Push(ctx, descriptor, content) +} + +// pushManifest uploads one manifest or index through oras. +func pushManifest( + ctx context.Context, + repo *remote.Repository, + descriptor ocispec.Descriptor, + content io.Reader, +) error { + return repo.Manifests().Push(ctx, descriptor, content) +} + +// isAlreadyPresent reports whether err means the content is already in the registry. +func isAlreadyPresent(err error) bool { + return errors.Is(err, errdef.ErrAlreadyExists) +} + +// ociDescriptor converts a domain descriptor into an oras descriptor. +func ociDescriptor(descriptor puboci.Descriptor) ocispec.Descriptor { + return ocispec.Descriptor{ + MediaType: descriptor.MediaType, + Digest: godigest.Digest(descriptor.Digest.String()), + Size: descriptor.Size, + } +} diff --git a/internal/adapter/reg/content_test.go b/internal/adapter/reg/content_test.go new file mode 100644 index 0000000..94eb9c5 --- /dev/null +++ b/internal/adapter/reg/content_test.go @@ -0,0 +1,423 @@ +package reg + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry/remote/errcode" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // testConfigJSON is a distinct config blob for push fixtures. + testConfigJSON = `{"architecture":"amd64","os":"linux"}` + // testLayerPayload is a distinct layer blob for push fixtures. + testLayerPayload = "layer-bytes" + // digestHexBytes is the decoded length of a SHA-256 hex digest. + digestHexBytes = 32 +) + +func TestClientImplementsPorts(t *testing.T) { + t.Parallel() + + client := New(Options{}) + var reader puboci.StateReader = client + var pusher puboci.ContentPusher = client + require.NotNil(t, reader) + require.NotNil(t, pusher) +} + +// TestPushBlobLeavesReaderOpen pins the ownership rule that produced a real +// bug: oras hands the content reader to net/http, which always closes a +// request body, so a caller-owned [os.File] was closed twice. +func TestPushBlobLeavesReaderOpen(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + client := newPlainClient(server) + image := mustImage(t, server) + fixture := newImageFixture(t) + + dir := t.TempDir() + name := filepath.Join(dir, "blob") + require.NoError(t, os.WriteFile(name, fixture.layerBytes, 0o600)) + file, err := os.Open(name) + require.NoError(t, err) + + require.NoError(t, client.PushBlob(context.Background(), image, fixture.layer, file)) + require.NoError(t, file.Close(), "the adapter must not close a caller-owned reader") + assert.Equal(t, fixture.layerBytes, getBlob(t, server, fixture.layer.Digest)) +} + +func TestPushImageRoundTrip(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + client := newPlainClient(server) + image := mustImage(t, server) + fixture := newImageFixture(t) + + require.NoError(t, client.PushBlob( + context.Background(), + image, + fixture.config, + bytes.NewReader(fixture.configBytes), + )) + require.NoError(t, client.PushBlob( + context.Background(), + image, + fixture.layer, + bytes.NewReader(fixture.layerBytes), + )) + require.NoError(t, client.PushManifest( + context.Background(), + image, + fixture.manifest, + bytes.NewReader(fixture.manifestBytes), + )) + require.NoError(t, client.PushManifest( + context.Background(), + image, + fixture.index, + bytes.NewReader(fixture.indexBytes), + )) + + require.NoError(t, client.Verify(context.Background(), image.Pin(fixture.index.Digest))) + require.NoError(t, client.Verify(context.Background(), image.Pin(fixture.manifest.Digest))) + indexBody, indexType := getManifest(t, server, fixture.index.Digest) + manifestBody, manifestType := getManifest(t, server, fixture.manifest.Digest) + assert.Equal(t, fixture.indexBytes, indexBody) + assert.Equal(t, ocispec.MediaTypeImageIndex, indexType) + assert.Equal(t, fixture.manifestBytes, manifestBody) + assert.Equal(t, ocispec.MediaTypeImageManifest, manifestType) + assert.Equal(t, fixture.layerBytes, getBlob(t, server, fixture.layer.Digest)) +} + +func TestPushBlobConvergesOnRetry(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + client := newPlainClient(server) + image := mustImage(t, server) + body := []byte(testLayerPayload) + desc := descriptorFor(t, ocispec.MediaTypeImageLayer, body) + + require.NoError(t, client.PushBlob(context.Background(), image, desc, bytes.NewReader(body))) + require.NoError(t, client.PushBlob(context.Background(), image, desc, bytes.NewReader(body))) + assert.Equal(t, body, getBlob(t, server, desc.Digest)) +} + +func TestPushBlobConflictIsError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusConflict) + })) + t.Cleanup(server.Close) + + body := []byte(testLayerPayload) + desc := descriptorFor(t, ocispec.MediaTypeImageLayer, body) + err := newPlainClient(server).PushBlob( + context.Background(), + mustImage(t, server), + desc, + bytes.NewReader(body), + ) + require.Error(t, err) + require.NotErrorIs(t, err, errdef.ErrAlreadyExists) + assert.Contains(t, err.Error(), "push blob") + assert.Contains(t, err.Error(), desc.Digest.String()) +} + +func TestAlreadyPresentIsSuccess(t *testing.T) { + t.Parallel() + + assert.True(t, isAlreadyPresent(errdef.ErrAlreadyExists)) + assert.True(t, isAlreadyPresent(errors.Join(errdef.ErrAlreadyExists))) + assert.False(t, isAlreadyPresent(nil)) + assert.False(t, isAlreadyPresent(&errcode.ErrorResponse{StatusCode: http.StatusConflict})) + assert.False(t, isAlreadyPresent(&errcode.ErrorResponse{StatusCode: http.StatusBadRequest})) +} + +func TestPushBlobRejectsDigestMismatch(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + client := newPlainClient(server) + image := mustImage(t, server) + body := []byte(testLayerPayload) + desc := descriptorFor(t, ocispec.MediaTypeImageLayer, []byte(testConfigJSON)) + desc.Size = int64(len(body)) + + err := client.PushBlob(context.Background(), image, desc, bytes.NewReader(body)) + require.Error(t, err) + assert.Contains(t, err.Error(), "push blob") + assert.Contains(t, err.Error(), desc.Digest.String()) + assert.NotContains(t, err.Error(), testToken) +} + +func TestVerifyAbsentDigestWrapsErrTagAbsent(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + missing, err := rel.ParseDigest("sha256:" + hex.EncodeToString(bytes.Repeat([]byte{0xab}, digestHexBytes))) + require.NoError(t, err) + + verifyErr := newPlainClient(server).Verify(context.Background(), mustImage(t, server).Pin(missing)) + require.Error(t, verifyErr) + require.ErrorIs(t, verifyErr, puboci.ErrTagAbsent) + assert.NotContains(t, verifyErr.Error(), testToken) +} + +func TestPushStatusClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + wantSent error + wantText string + }{ + { + name: "service unavailable", + status: http.StatusServiceUnavailable, + wantSent: puboci.ErrRetryable, + wantText: "retryable", + }, + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantText: "registry authentication failed", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(test.status) + })) + t.Cleanup(server.Close) + + client := New(Options{ + Credentials: Credentials{ + Username: "octocat", + Password: rel.NewSecret(testToken), + }, + PlainHTTP: true, + HTTPClient: server.Client(), + }) + body := []byte(testLayerPayload) + err := client.PushBlob( + context.Background(), + mustImage(t, server), + descriptorFor(t, ocispec.MediaTypeImageLayer, body), + bytes.NewReader(body), + ) + require.Error(t, err) + if test.wantSent != nil { + require.ErrorIs(t, err, test.wantSent) + } else { + require.NotErrorIs(t, err, puboci.ErrRetryable) + require.NotErrorIs(t, err, puboci.ErrTagAbsent) + } + assert.Contains(t, err.Error(), test.wantText) + assert.NotContains(t, err.Error(), testToken) + assert.NotContains(t, err.Error(), "Authorization") + assert.NotContains(t, err.Error(), server.URL) + }) + } +} + +func TestCanceledPushDoesNotComplete(t *testing.T) { + t.Parallel() + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + hits.Add(1) + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + body := []byte(testLayerPayload) + err := newPlainClient(server).PushBlob( + ctx, + mustImage(t, server), + descriptorFor(t, ocispec.MediaTypeImageLayer, body), + bytes.NewReader(body), + ) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, hits.Load()) + assert.NotContains(t, err.Error(), server.URL) + assert.NotContains(t, err.Error(), testToken) +} + +func TestPushAndVerifyRejectNil(t *testing.T) { + t.Parallel() + + client := New(Options{}) + image, err := puboci.ParseImage("ghcr.io/" + testRepo) + require.NoError(t, err) + body := []byte(testLayerPayload) + desc := descriptorFor(t, ocispec.MediaTypeImageLayer, body) + ref := image.Pin(desc.Digest) + + var missing context.Context + require.EqualError(t, client.PushBlob(missing, image, desc, bytes.NewReader(body)), "context is nil") + require.EqualError(t, client.PushManifest(missing, image, desc, bytes.NewReader(body)), "context is nil") + require.EqualError(t, client.Verify(missing, ref), "context is nil") + require.EqualError( + t, + (*Client)(nil).PushBlob(context.Background(), image, desc, bytes.NewReader(body)), + "registry client is nil", + ) + require.EqualError( + t, + (*Client)(nil).PushManifest(context.Background(), image, desc, bytes.NewReader(body)), + "registry client is nil", + ) + require.EqualError(t, (*Client)(nil).Verify(context.Background(), ref), "registry client is nil") + require.EqualError( + t, + client.PushBlob(context.Background(), image, desc, nil), + "push blob "+desc.Digest.String()+": content is nil", + ) + require.EqualError( + t, + client.PushManifest(context.Background(), image, desc, nil), + "push manifest "+desc.Digest.String()+": content is nil", + ) +} + +// imageFixture is a one-platform image used by digest-push tests. +type imageFixture struct { + // configBytes is the config blob. + configBytes []byte + // layerBytes is the layer blob. + layerBytes []byte + // manifestBytes is the platform manifest document. + manifestBytes []byte + // indexBytes is the image index document. + indexBytes []byte + // config is the config descriptor. + config puboci.Descriptor + // layer is the layer descriptor. + layer puboci.Descriptor + // manifest is the platform manifest descriptor. + manifest puboci.Descriptor + // index is the image index descriptor. + index puboci.Descriptor +} + +// newImageFixture builds a config, layer, platform manifest, and index. +func newImageFixture(t *testing.T) imageFixture { + t.Helper() + + configBytes := []byte(testConfigJSON) + layerBytes := []byte(testLayerPayload) + config := descriptorFor(t, ocispec.MediaTypeImageConfig, configBytes) + layer := descriptorFor(t, ocispec.MediaTypeImageLayer, layerBytes) + manifestBytes := encodeJSON(t, ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: ociSchemaVersion}, + MediaType: ocispec.MediaTypeImageManifest, + Config: ociDescriptor(config), + Layers: []ocispec.Descriptor{ociDescriptor(layer)}, + }) + manifest := descriptorFor(t, ocispec.MediaTypeImageManifest, manifestBytes) + manifestDesc := ociDescriptor(manifest) + manifestDesc.Platform = &ocispec.Platform{OS: "linux", Architecture: "amd64"} + indexBytes := encodeJSON(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: ociSchemaVersion}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{manifestDesc}, + }) + + return imageFixture{ + configBytes: configBytes, + layerBytes: layerBytes, + manifestBytes: manifestBytes, + indexBytes: indexBytes, + config: config, + layer: layer, + manifest: manifest, + index: descriptorFor(t, ocispec.MediaTypeImageIndex, indexBytes), + } +} + +// descriptorFor returns the descriptor of data at mediaType. +func descriptorFor(t *testing.T, mediaType string, data []byte) puboci.Descriptor { + t.Helper() + + digest, err := rel.ParseDigest(digestOf(data)) + require.NoError(t, err) + + return puboci.Descriptor{ + MediaType: mediaType, + Digest: digest, + Size: int64(len(data)), + } +} + +// encodeJSON marshals value as JSON. +func encodeJSON(t *testing.T, value any) []byte { + t.Helper() + + body, err := json.Marshal(value) + require.NoError(t, err) + + return body +} + +// getManifest fetches a digest-addressed manifest and its stored media type. +func getManifest(t *testing.T, server *httptest.Server, digest rel.Digest) ([]byte, string) { + t.Helper() + + return getPath(t, server, "/v2/"+testRepo+"/manifests/"+digest.String()) +} + +// getBlob fetches a digest-addressed blob from the fake registry. +func getBlob(t *testing.T, server *httptest.Server, digest rel.Digest) []byte { + t.Helper() + + body, _ := getPath(t, server, "/v2/"+testRepo+"/blobs/"+digest.String()) + + return body +} + +// getPath GETs path from server and returns the response body and Content-Type. +func getPath(t *testing.T, server *httptest.Server, path string) ([]byte, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL+path, nil) + require.NoError(t, err) + resp, err := server.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + return body, resp.Header.Get("Content-Type") +} diff --git a/internal/adapter/reg/doc.go b/internal/adapter/reg/doc.go index 09bdc3f..72bf997 100644 --- a/internal/adapter/reg/doc.go +++ b/internal/adapter/reg/doc.go @@ -1,7 +1,9 @@ -// Package reg implements [puboci.StateReader] with oras-go. +// Package reg implements [puboci.StateReader] and [puboci.ContentPusher] with oras-go. // -// [New] builds a read-only registry client. Token text is applied only when -// building a per-request authenticated transport and is never stored in a -// formattable field or included in returned errors. Resolve and Version -// classify registry failures as absent, auth, retryable, or corrupt. +// [New] builds a registry client for tag reads and digest-addressed writes. +// Token text is applied only when building a per-request authenticated +// transport and is never stored in a formattable field or included in returned +// errors. The client never creates, moves, or deletes a tag. Resolve, Version, +// PushBlob, PushManifest, and Verify classify registry failures as absent, +// auth, retryable, or corrupt. An already-present blob or manifest is success. package reg diff --git a/internal/adapter/reg/mocks/content_pusher.go b/internal/adapter/reg/mocks/content_pusher.go new file mode 100644 index 0000000..6e9b108 --- /dev/null +++ b/internal/adapter/reg/mocks/content_pusher.go @@ -0,0 +1,236 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + "io" + + mock "github.com/stretchr/testify/mock" + + "github.com/meigma/release/internal/stage/puboci" +) + +// NewMockContentPusher creates a new instance of MockContentPusher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockContentPusher(t interface { + mock.TestingT + Cleanup(func()) +}) *MockContentPusher { + mock := &MockContentPusher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockContentPusher is an autogenerated mock type for the ContentPusher type +type MockContentPusher struct { + mock.Mock +} + +type MockContentPusher_Expecter struct { + mock *mock.Mock +} + +func (_m *MockContentPusher) EXPECT() *MockContentPusher_Expecter { + return &MockContentPusher_Expecter{mock: &_m.Mock} +} + +// PushBlob provides a mock function for the type MockContentPusher +func (_mock *MockContentPusher) PushBlob(ctx context.Context, image puboci.Image, descriptor puboci.Descriptor, content io.Reader) error { + ret := _mock.Called(ctx, image, descriptor, content) + + if len(ret) == 0 { + panic("no return value specified for PushBlob") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.Image, puboci.Descriptor, io.Reader) error); ok { + r0 = returnFunc(ctx, image, descriptor, content) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockContentPusher_PushBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushBlob' +type MockContentPusher_PushBlob_Call struct { + *mock.Call +} + +// PushBlob is a helper method to define mock.On call +// - ctx context.Context +// - image puboci.Image +// - descriptor puboci.Descriptor +// - content io.Reader +func (_e *MockContentPusher_Expecter) PushBlob(ctx any, image any, descriptor any, content any) *MockContentPusher_PushBlob_Call { + return &MockContentPusher_PushBlob_Call{Call: _e.mock.On("PushBlob", ctx, image, descriptor, content)} +} + +func (_c *MockContentPusher_PushBlob_Call) Run(run func(ctx context.Context, image puboci.Image, descriptor puboci.Descriptor, content io.Reader)) *MockContentPusher_PushBlob_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 puboci.Image + if args[1] != nil { + arg1 = args[1].(puboci.Image) + } + var arg2 puboci.Descriptor + if args[2] != nil { + arg2 = args[2].(puboci.Descriptor) + } + var arg3 io.Reader + if args[3] != nil { + arg3 = args[3].(io.Reader) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockContentPusher_PushBlob_Call) Return(err error) *MockContentPusher_PushBlob_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockContentPusher_PushBlob_Call) RunAndReturn(run func(ctx context.Context, image puboci.Image, descriptor puboci.Descriptor, content io.Reader) error) *MockContentPusher_PushBlob_Call { + _c.Call.Return(run) + return _c +} + +// PushManifest provides a mock function for the type MockContentPusher +func (_mock *MockContentPusher) PushManifest(ctx context.Context, image puboci.Image, descriptor puboci.Descriptor, content io.Reader) error { + ret := _mock.Called(ctx, image, descriptor, content) + + if len(ret) == 0 { + panic("no return value specified for PushManifest") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.Image, puboci.Descriptor, io.Reader) error); ok { + r0 = returnFunc(ctx, image, descriptor, content) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockContentPusher_PushManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushManifest' +type MockContentPusher_PushManifest_Call struct { + *mock.Call +} + +// PushManifest is a helper method to define mock.On call +// - ctx context.Context +// - image puboci.Image +// - descriptor puboci.Descriptor +// - content io.Reader +func (_e *MockContentPusher_Expecter) PushManifest(ctx any, image any, descriptor any, content any) *MockContentPusher_PushManifest_Call { + return &MockContentPusher_PushManifest_Call{Call: _e.mock.On("PushManifest", ctx, image, descriptor, content)} +} + +func (_c *MockContentPusher_PushManifest_Call) Run(run func(ctx context.Context, image puboci.Image, descriptor puboci.Descriptor, content io.Reader)) *MockContentPusher_PushManifest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 puboci.Image + if args[1] != nil { + arg1 = args[1].(puboci.Image) + } + var arg2 puboci.Descriptor + if args[2] != nil { + arg2 = args[2].(puboci.Descriptor) + } + var arg3 io.Reader + if args[3] != nil { + arg3 = args[3].(io.Reader) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockContentPusher_PushManifest_Call) Return(err error) *MockContentPusher_PushManifest_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockContentPusher_PushManifest_Call) RunAndReturn(run func(ctx context.Context, image puboci.Image, descriptor puboci.Descriptor, content io.Reader) error) *MockContentPusher_PushManifest_Call { + _c.Call.Return(run) + return _c +} + +// Verify provides a mock function for the type MockContentPusher +func (_mock *MockContentPusher) Verify(ctx context.Context, ref puboci.DigestRef) error { + ret := _mock.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for Verify") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.DigestRef) error); ok { + r0 = returnFunc(ctx, ref) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockContentPusher_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' +type MockContentPusher_Verify_Call struct { + *mock.Call +} + +// Verify is a helper method to define mock.On call +// - ctx context.Context +// - ref puboci.DigestRef +func (_e *MockContentPusher_Expecter) Verify(ctx any, ref any) *MockContentPusher_Verify_Call { + return &MockContentPusher_Verify_Call{Call: _e.mock.On("Verify", ctx, ref)} +} + +func (_c *MockContentPusher_Verify_Call) Run(run func(ctx context.Context, ref puboci.DigestRef)) *MockContentPusher_Verify_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 puboci.DigestRef + if args[1] != nil { + arg1 = args[1].(puboci.DigestRef) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockContentPusher_Verify_Call) Return(err error) *MockContentPusher_Verify_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockContentPusher_Verify_Call) RunAndReturn(run func(ctx context.Context, ref puboci.DigestRef) error) *MockContentPusher_Verify_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/reg/state.go b/internal/adapter/reg/state.go index 08b3d58..d0a0fe8 100644 --- a/internal/adapter/reg/state.go +++ b/internal/adapter/reg/state.go @@ -36,11 +36,8 @@ type annotationFile struct { // Resolve implements [puboci.StateReader]. func (c *Client) Resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, error) { - if ctx == nil { - return "", errors.New("context is nil") - } - if c == nil || c.auth == nil { - return "", errors.New("registry client is nil") + if err := c.requireReady(ctx); err != nil { + return "", err } return c.resolve(ctx, ref) @@ -48,11 +45,8 @@ func (c *Client) Resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, // Version implements [puboci.StateReader]. func (c *Client) Version(ctx context.Context, ref puboci.Reference) (rel.Version, error) { - if ctx == nil { - return rel.Version{}, errors.New("context is nil") - } - if c == nil || c.auth == nil { - return rel.Version{}, errors.New("registry client is nil") + if err := c.requireReady(ctx); err != nil { + return rel.Version{}, err } return c.version(ctx, ref) @@ -60,7 +54,7 @@ func (c *Client) Version(ctx context.Context, ref puboci.Reference) (rel.Version // resolve looks up the digest for ref after exported guards. func (c *Client) resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, error) { - repo, err := c.repository(ref) + repo, err := c.repository(ref.Image) if err != nil { return "", err } @@ -80,7 +74,7 @@ func (c *Client) resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, // version reads the version annotation for ref after exported guards. func (c *Client) version(ctx context.Context, ref puboci.Reference) (rel.Version, error) { - repo, err := c.repository(ref) + repo, err := c.repository(ref.Image) if err != nil { return rel.Version{}, err } diff --git a/internal/adapter/reg/state_test.go b/internal/adapter/reg/state_test.go index 32c32e0..a95aa1c 100644 --- a/internal/adapter/reg/state_test.go +++ b/internal/adapter/reg/state_test.go @@ -340,16 +340,24 @@ func digestOf(body []byte) string { return "sha256:" + hex.EncodeToString(sum[:]) } -// mustRef builds the fixture reference for testRepo and testTag on server. -func mustRef(t *testing.T, server *httptest.Server) puboci.Reference { +// mustImage builds the fixture image for testRepo on server. +func mustImage(t *testing.T, server *httptest.Server) puboci.Image { t.Helper() parsed, err := url.Parse(server.URL) require.NoError(t, err) image, err := puboci.ParseImage(parsed.Host + "/" + testRepo) require.NoError(t, err) + + return image +} + +// mustRef builds the fixture reference for testRepo and testTag on server. +func mustRef(t *testing.T, server *httptest.Server) puboci.Reference { + t.Helper() + tag, err := rel.ParseTag(testTag) require.NoError(t, err) - return image.Reference(tag) + return mustImage(t, server).Reference(tag) } diff --git a/internal/cli/oci.go b/internal/cli/oci.go new file mode 100644 index 0000000..72f3315 --- /dev/null +++ b/internal/cli/oci.go @@ -0,0 +1,266 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // commandPrepare is the envelope command path for publish oci prepare. + commandPrepare = "publish oci prepare" + // flagLayout is the prepare layout-directory flag name. + flagLayout = "layout" + // flagDryRun is the prepare dry-run flag name. + flagDryRun = "dry-run" + // flagPlainHTTP is the registry plain-HTTP flag name. + flagPlainHTTP = "plain-http" + // envCosignPath is the Cosign binary path override. + envCosignPath = "RELEASE_COSIGN_PATH" +) + +// newPublishCommand constructs the publish parent verb. +func newPublishCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish release artifacts", + Args: requireSubcommand, + RunE: func(_ *cobra.Command, _ []string) error { + return UsageError(errors.New("a publish subcommand is required")) + }, + } + cmd.AddCommand(newOCICommand(options)) + + return cmd +} + +// newOCICommand constructs the publish oci verb. +func newOCICommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "oci", + Short: "Publish OCI images", + Args: requireSubcommand, + RunE: func(_ *cobra.Command, _ []string) error { + return UsageError(errors.New("an oci subcommand is required")) + }, + } + cmd.AddCommand(newPrepareCommand(options)) + + return cmd +} + +// newPrepareCommand constructs the publish oci prepare verb. +func newPrepareCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "prepare", + Short: "Prepare a digest-addressed OCI image publication", + Args: usageNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPrepare(cmd, options) + }, + } + cmd.Flags().String(flagLayout, "", "path to the extracted oci-image/layout directory") + cmd.Flags().String(flagImage, "", "OCI image name without a tag or digest") + cmd.Flags().String(flagVersion, "", "stable MAJOR.MINOR.PATCH version") + cmd.Flags().String(flagDigest, "", "expected OCI index digest") + cmd.Flags().Bool(flagDryRun, false, "validate and plan without writing or signing") + cmd.Flags().Bool(flagPlainHTTP, false, "use HTTP instead of HTTPS for the registry") + + return cmd +} + +// runPrepare validates configuration and prepares a digest-addressed publication. +// +// Missing or malformed configuration is [ErrUsage] and is raised before any +// port is constructed. Opening the layout and publication failures are +// command failures. Success without --json writes nothing. The --json +// envelope result is the [puboci.OCIPrepareResult] itself. +func runPrepare(cmd *cobra.Command, options Options) error { + expected, err := resolvePrepare(options) + if err != nil { + return writeCommandResult(options, commandPrepare, nil, UsageError(err)) + } + + root, err := os.OpenRoot(expected.Layout) + if err != nil { + return writeCommandResult( + options, + commandPrepare, + nil, + fmt.Errorf("open layout %s: %w", expected.Layout, err), + ) + } + defer root.Close() + + reader, err := stateReader(options, expected.Registry) + if err != nil { + return writeCommandResult(options, commandPrepare, nil, err) + } + + pusher, signer, err := prepareWriters(options, expected) + if err != nil { + return writeCommandResult(options, commandPrepare, nil, err) + } + + result, err := puboci.Prepare(cmd.Context(), puboci.PrepareInput{ + Image: expected.Image, + Version: expected.Version, + IndexDigest: expected.Digest, + Layout: root.FS(), + DryRun: expected.DryRun, + }, reader, pusher, signer) + if err != nil { + return writeCommandResult(options, commandPrepare, nil, err) + } + if options.settings == nil || !options.settings.JSON { + return nil + } + + return writeCommandResult(options, commandPrepare, result, nil) +} + +// prepareConfig is the resolved publish-oci-prepare configuration. +type prepareConfig struct { + // Image is the untagged repository to publish. + Image puboci.Image + // Version is the candidate stable release version. + Version rel.Version + // Digest is the expected image index digest. + Digest rel.Digest + // Layout is the extracted oci-image/layout directory. + Layout string + // DryRun skips registry writes and Cosign signing. + DryRun bool + // Registry authenticates and configures the registry client. + Registry RegistryConfig + // CosignPath is RELEASE_COSIGN_PATH. Empty resolves cosign from PATH. + CosignPath string +} + +// resolvePrepare parses flags and Actions environment into a prepare config. +// +// It performs no I/O. +func resolvePrepare(options Options) (prepareConfig, error) { + settings := Settings{} + if options.settings != nil { + settings = *options.settings + } + if err := settings.err; err != nil { + return prepareConfig{}, err + } + if settings.Layout == "" { + return prepareConfig{}, fmt.Errorf("--%s is required", flagLayout) + } + + digest, err := resolvePlanDigest(settings) + if err != nil { + return prepareConfig{}, err + } + image, err := resolvePlanImage(settings, options.LookupEnv) + if err != nil { + return prepareConfig{}, err + } + if plainErr := requireLoopbackPlainHTTP(image, settings.PlainHTTP); plainErr != nil { + return prepareConfig{}, plainErr + } + version, err := resolvePlanVersion(settings, options.LookupEnv) + if err != nil { + return prepareConfig{}, err + } + + return prepareConfig{ + Image: image, + Version: version, + Digest: digest, + Layout: settings.Layout, + DryRun: settings.DryRun, + Registry: resolveRegistryConfig(settings, options.LookupEnv), + CosignPath: resolveCosignPath(options.LookupEnv), + }, nil +} + +// resolveRegistryConfig combines credentials with the plain-HTTP setting. +func resolveRegistryConfig(settings Settings, lookup LookupEnv) RegistryConfig { + return RegistryConfig{ + Credentials: resolveRegistryCredentials(lookup), + PlainHTTP: settings.PlainHTTP, + } +} + +// resolveCosignPath returns RELEASE_COSIGN_PATH, or empty to resolve from PATH. +func resolveCosignPath(lookup LookupEnv) string { + if lookup == nil { + return "" + } + value, ok := lookup(envCosignPath) + if !ok { + return "" + } + + return value +} + +// prepareWriters returns the write and sign ports for a real prepare. +// +// A dry run returns nil ports and does not construct them. +func prepareWriters(options Options, expected prepareConfig) (puboci.ContentPusher, puboci.Signer, error) { + if expected.DryRun { + return nil, nil, nil + } + + pusher, err := contentPusher(options, expected.Registry) + if err != nil { + return nil, nil, err + } + signer, err := contentSigner(options, expected.CosignPath) + if err != nil { + return nil, nil, err + } + + return pusher, signer, nil +} + +// contentPusher returns the injected write port or constructs one. +func contentPusher(options Options, config RegistryConfig) (puboci.ContentPusher, error) { + if options.ContentPusher != nil { + return options.ContentPusher, nil + } + if options.NewContentPusher == nil { + return nil, errors.New("content pusher factory is not configured") + } + + pusher, err := options.NewContentPusher(config) + if err != nil { + return nil, UsageError(fmt.Errorf("registry client: %w", err)) + } + if pusher == nil { + return nil, errors.New("content pusher factory returned nil") + } + + return pusher, nil +} + +// contentSigner returns the injected signing port or constructs one. +func contentSigner(options Options, path string) (puboci.Signer, error) { + if options.Signer != nil { + return options.Signer, nil + } + if options.NewSigner == nil { + return nil, errors.New("signer factory is not configured") + } + + signer, err := options.NewSigner(path) + if err != nil { + return nil, UsageError(fmt.Errorf("cosign: %w", err)) + } + if signer == nil { + return nil, errors.New("signer factory returned nil") + } + + return signer, nil +} diff --git a/internal/cli/oci_test.go b/internal/cli/oci_test.go new file mode 100644 index 0000000..07072b8 --- /dev/null +++ b/internal/cli/oci_test.go @@ -0,0 +1,830 @@ +package cli_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + godigest "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + cosignmocks "github.com/meigma/release/internal/adapter/cosign/mocks" + regmocks "github.com/meigma/release/internal/adapter/reg/mocks" + "github.com/meigma/release/internal/cli" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // prepareOCISchemaVersion is the OCI image-spec schemaVersion used in fixtures. + prepareOCISchemaVersion = 2 + // prepareLayoutJSON is a valid oci-layout marker document. + prepareLayoutJSON = `{"imageLayoutVersion":"1.0.0"}` + // prepareAMD64Config is a distinct amd64 config blob. + prepareAMD64Config = `{"architecture":"amd64","os":"linux"}` + // prepareARM64Config is a distinct arm64 config blob. + prepareARM64Config = `{"architecture":"arm64","os":"linux"}` + // prepareAMD64Layer is a distinct amd64 layer blob. + prepareAMD64Layer = "amd64-layer" + // prepareARM64Layer is a distinct arm64 layer blob. + prepareARM64Layer = "arm64-layer" + // prepareCosignPath is the RELEASE_COSIGN_PATH fixture. + prepareCosignPath = "/opt/cosign" + // prepareCommand is the envelope command path for publish oci prepare. + prepareCommand = "publish oci prepare" +) + +func TestPublishOCIPrepareMissingValuesAreUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing layout", + args: []string{ + "publish", "oci", "prepare", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + }, + want: "--layout is required", + }, + { + name: "missing digest", + args: []string{ + "publish", "oci", "prepare", + "--layout", "/unused", + "--image", tagsImage, + "--version", "1.2.3", + }, + want: "--digest is required", + }, + { + name: "malformed digest", + args: []string{ + "publish", "oci", "prepare", + "--layout", "/unused", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", "not-a-digest", + }, + want: "digest", + }, + { + name: "malformed version", + args: []string{ + "publish", "oci", "prepare", + "--layout", "/unused", + "--image", tagsImage, + "--version", "v1.2.3", + "--digest", tagsDigest, + }, + want: "v prefix", + }, + { + name: "malformed image", + args: []string{ + "publish", "oci", "prepare", + "--layout", "/unused", + "--image", "GHCR.IO/OWNER/REPO", + "--version", "1.2.3", + "--digest", tagsDigest, + }, + want: "uppercase", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executePrepareFactory(t, nil, tt.args, trackingPrepareFactories(t, &called)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), tt.want) + assert.False(t, called) + }) + } +} + +func TestPublishOCIPrepareJSONConfigFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing layout", + args: []string{ + "publish", "oci", "prepare", + "--json", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + }, + want: "--layout is required", + }, + { + name: "missing digest", + args: []string{ + "publish", "oci", "prepare", + "--json", + "--layout", "/unused", + "--image", tagsImage, + "--version", "1.2.3", + }, + want: "--digest is required", + }, + { + name: "malformed digest", + args: []string{ + "publish", "oci", "prepare", + "--json", + "--layout", "/unused", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", "not-a-digest", + }, + want: "digest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executePrepareFactory(t, nil, tt.args, trackingPrepareFactories(t, &called)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.False(t, called) + assertPrepareFailureEnvelope(t, stdout, tt.want) + }) + } +} + +func TestPublishOCIPrepareInvalidLayoutPath(t *testing.T) { + t.Parallel() + + fileLayout := filepath.Join(t.TempDir(), "not-a-directory") + require.NoError(t, os.WriteFile(fileLayout, []byte("file"), 0o644)) + + tests := []struct { + name string + layout string + }{ + { + name: "missing directory", + layout: filepath.Join(t.TempDir(), "missing"), + }, + { + name: "path is a file", + layout: fileLayout, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executePrepareFactory(t, nil, []string{ + "publish", "oci", "prepare", + "--json", + "--layout", tt.layout, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + }, trackingPrepareFactories(t, &called)) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "open layout") + assert.Contains(t, err.Error(), tt.layout) + assert.False(t, called) + assertPrepareFailureEnvelope(t, stdout, "open layout") + }) + } +} + +func TestPublishOCIPrepareDryRunJSON(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + readerCalled := false + writerCalled := false + + stdout, err := executePrepareFactory(t, nil, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--dry-run", + "--json", + }, prepareFactories{ + newReader: func(cli.RegistryConfig) (puboci.StateReader, error) { + readerCalled = true + return absentReader(t), nil + }, + newPusher: func(cli.RegistryConfig) (puboci.ContentPusher, error) { + writerCalled = true + return unusedPusher(t), nil + }, + newSigner: func(string) (puboci.Signer, error) { + writerCalled = true + return unusedSigner(t), nil + }, + }) + require.NoError(t, err) + require.True(t, readerCalled) + assert.False(t, writerCalled) + + result := decodePrepareResult(t, stdout) + assert.False(t, result.Authoritative) + assert.Equal(t, layout.Index.Digest.String(), result.IndexDigest) + assert.Equal(t, []puboci.AttestationSubject{ + {Platform: "linux/amd64", Digest: layout.Platforms[0].Descriptor.Digest.String()}, + {Platform: "linux/arm64", Digest: layout.Platforms[1].Descriptor.Digest.String()}, + }, result.Platforms) +} + +func TestPublishOCIPrepareJSONSuccess(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + image := mustImage(t) + pusher := unusedPusher(t) + signer := unusedSigner(t) + expectSuccessfulPrepare(t, image, layout, pusher, signer) + + stdout, stderr, err := executePrepare(t, map[string]string{ + "GITHUB_TOKEN": tagsToken, + }, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--json", + }, preparePorts{ + reader: absentReader(t), + pusher: pusher, + signer: signer, + }) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + assert.NotContains(t, stdout, tagsToken) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, prepareCommand, envelope.Command) + assert.True(t, envelope.OK) + + result := decodePrepareResult(t, stdout) + assert.True(t, result.Authoritative) + assert.Equal(t, puboci.PrepareSchema, result.Schema) + assert.Equal(t, tagsImage, result.Image) + assert.Equal(t, "1.2.3", result.Version) + assert.Equal(t, layout.Index.Digest.String(), result.IndexDigest) + assert.Equal(t, []puboci.AttestationSubject{ + {Platform: "linux/amd64", Digest: layout.Platforms[0].Descriptor.Digest.String()}, + {Platform: "linux/arm64", Digest: layout.Platforms[1].Descriptor.Digest.String()}, + }, result.Platforms) + assert.Equal(t, []puboci.TagObservation{ + {Tag: "1.2.3", Scope: string(rel.ScopeExact), Present: false}, + {Tag: "1.2", Scope: string(rel.ScopeMinor), Present: false}, + {Tag: "1", Scope: string(rel.ScopeMajor), Present: false}, + {Tag: "latest", Scope: string(rel.ScopeLatest), Present: false}, + }, result.Observed) +} + +func TestPublishOCIPrepareSilentSuccess(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + image := mustImage(t) + pusher := unusedPusher(t) + signer := unusedSigner(t) + expectSuccessfulPrepare(t, image, layout, pusher, signer) + + stdout, stderr, err := executePrepare(t, nil, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + }, preparePorts{ + reader: absentReader(t), + pusher: pusher, + signer: signer, + }) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestPublishOCIPreparePublicationFailure(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + image := mustImage(t) + first := layout.Blobs[0] + pusher := unusedPusher(t) + pusher.EXPECT(). + PushBlob(mock.Anything, image, first, mock.Anything). + Return(errors.New("blob rejected")). + Once() + + stdout, _, err := executePrepare(t, map[string]string{ + "GITHUB_TOKEN": tagsToken, + }, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--json", + }, preparePorts{ + reader: absentReader(t), + pusher: pusher, + signer: unusedSigner(t), + }) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "push blob") + assert.NotContains(t, err.Error(), tagsToken) + assert.NotContains(t, stdout, tagsToken) + assertPrepareFailureEnvelope(t, stdout, "push blob") +} + +func TestPublishOCIPrepareRegistryConfig(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + image, err := puboci.ParseImage("127.0.0.1:5000/owner/repo") + require.NoError(t, err) + reader := absentReader(t) + pusher := unusedPusher(t) + signer := unusedSigner(t) + expectSuccessfulPrepare(t, image, layout, pusher, signer) + + var gotReader cli.RegistryConfig + var gotPusher cli.RegistryConfig + var gotPath string + stdout, err := executePrepareFactory(t, map[string]string{ + "GITHUB_TOKEN": tagsToken, + "GITHUB_ACTOR": "octocat", + "RELEASE_COSIGN_PATH": prepareCosignPath, + }, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", "127.0.0.1:5000/owner/repo", + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--plain-http", + "--json", + }, prepareFactories{ + newReader: func(config cli.RegistryConfig) (puboci.StateReader, error) { + gotReader = config + return reader, nil + }, + newPusher: func(config cli.RegistryConfig) (puboci.ContentPusher, error) { + gotPusher = config + return pusher, nil + }, + newSigner: func(path string) (puboci.Signer, error) { + gotPath = path + return signer, nil + }, + }) + require.NoError(t, err) + assert.Equal(t, gotReader, gotPusher) + assert.Equal(t, "octocat", gotReader.Credentials.Username) + assert.Equal(t, tagsToken, gotReader.Credentials.Password.Reveal()) + assert.True(t, gotReader.PlainHTTP) + assert.Equal(t, prepareCosignPath, gotPath) + assert.NotContains(t, stdout, tagsToken) + assert.True(t, decodePrepareResult(t, stdout).Authoritative) +} + +func TestPublishOCIPrepareDryRunEnvYesIsUsage(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + called := false + stdout, err := executePrepareFactory(t, map[string]string{ + "RELEASE_DRY_RUN": "yes", + }, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--json", + }, trackingPrepareFactories(t, &called)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.False(t, called) + assert.Contains(t, err.Error(), "RELEASE_DRY_RUN") + assertPrepareFailureEnvelope(t, stdout, "RELEASE_DRY_RUN") +} + +func TestPublishOCIPrepareDryRunEnvTrue(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + readerCalled := false + writerCalled := false + stdout, err := executePrepareFactory(t, map[string]string{ + "RELEASE_DRY_RUN": "true", + }, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--json", + }, prepareFactories{ + newReader: func(cli.RegistryConfig) (puboci.StateReader, error) { + readerCalled = true + return absentReader(t), nil + }, + newPusher: func(cli.RegistryConfig) (puboci.ContentPusher, error) { + writerCalled = true + return unusedPusher(t), nil + }, + newSigner: func(string) (puboci.Signer, error) { + writerCalled = true + return unusedSigner(t), nil + }, + }) + require.NoError(t, err) + require.True(t, readerCalled) + assert.False(t, writerCalled) + assert.False(t, decodePrepareResult(t, stdout).Authoritative) +} + +func TestPublishOCIPreparePlainHTTPRefusedForGHCR(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + called := false + stdout, err := executePrepareFactory(t, nil, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--plain-http", + "--json", + }, trackingPrepareFactories(t, &called)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.False(t, called) + assert.Contains(t, err.Error(), "--plain-http") + assertPrepareFailureEnvelope(t, stdout, "--plain-http") +} + +func TestPublishOCIPrepareSymlinkEscape(t *testing.T) { + t.Parallel() + + layoutDir, layout := writeTwoPlatformLayout(t) + outside := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o644)) + blobName, err := puboci.BlobPath(layout.Blobs[0].Digest) + require.NoError(t, err) + blobPath := filepath.Join(layoutDir, filepath.FromSlash(blobName)) + require.NoError(t, os.Remove(blobPath)) + require.NoError(t, os.Symlink(outside, blobPath)) + + stdout, _, err := executePrepare(t, nil, []string{ + "publish", "oci", "prepare", + "--layout", layoutDir, + "--image", tagsImage, + "--version", "1.2.3", + "--digest", layout.Index.Digest.String(), + "--json", + }, preparePorts{ + reader: unusedReader(t), + pusher: unusedPusher(t), + signer: unusedSigner(t), + }) + require.Error(t, err) + assert.NotEqual(t, 0, cli.ExitCode(err)) + assert.Equal(t, 1, countJSONDocuments(stdout)) +} + +// preparePorts is the injected prepare command ports. +type preparePorts struct { + // reader is the registry read port. + reader puboci.StateReader + // pusher is the registry write port. + pusher puboci.ContentPusher + // signer is the Cosign signing port. + signer puboci.Signer +} + +// prepareFactories constructs prepare ports from resolved configuration. +type prepareFactories struct { + // newReader constructs the registry read port. + newReader func(cli.RegistryConfig) (puboci.StateReader, error) + // newPusher constructs the registry write port. + newPusher func(cli.RegistryConfig) (puboci.ContentPusher, error) + // newSigner constructs the Cosign signing port. + newSigner func(string) (puboci.Signer, error) +} + +// trackingPrepareFactories records whether any prepare factory was invoked. +func trackingPrepareFactories(t *testing.T, called *bool) prepareFactories { + t.Helper() + + return prepareFactories{ + newReader: func(cli.RegistryConfig) (puboci.StateReader, error) { + *called = true + return unusedReader(t), nil + }, + newPusher: func(cli.RegistryConfig) (puboci.ContentPusher, error) { + *called = true + return unusedPusher(t), nil + }, + newSigner: func(string) (puboci.Signer, error) { + *called = true + return unusedSigner(t), nil + }, + } +} + +// unusedPusher returns a generated mock that fails if the port is called. +func unusedPusher(t *testing.T) *regmocks.MockContentPusher { + t.Helper() + + return regmocks.NewMockContentPusher(t) +} + +// unusedSigner returns a generated mock that fails if the port is called. +func unusedSigner(t *testing.T) *cosignmocks.MockSigner { + t.Helper() + + return cosignmocks.NewMockSigner(t) +} + +// expectSuccessfulPrepare expects the full push, verify, and sign sequence. +func expectSuccessfulPrepare( + t *testing.T, + image puboci.Image, + layout puboci.Layout, + pusher *regmocks.MockContentPusher, + signer *cosignmocks.MockSigner, +) { + t.Helper() + + for _, blob := range layout.Blobs { + pusher.EXPECT(). + PushBlob(mock.Anything, image, blob, mock.Anything). + Return(nil). + Once() + } + for _, platform := range layout.Platforms { + pusher.EXPECT(). + PushManifest(mock.Anything, image, platform.Descriptor, mock.Anything). + Return(nil). + Once() + } + pusher.EXPECT(). + PushManifest(mock.Anything, image, layout.Index, mock.Anything). + Return(nil). + Once() + pusher.EXPECT(). + Verify(mock.Anything, image.Pin(layout.Index.Digest)). + Return(nil). + Once() + for _, platform := range layout.Platforms { + pusher.EXPECT(). + Verify(mock.Anything, image.Pin(platform.Descriptor.Digest)). + Return(nil). + Once() + } + signer.EXPECT(). + SignRecursive(mock.Anything, image.Pin(layout.Index.Digest)). + Return(nil). + Once() +} + +// executePrepare runs publish oci prepare with injected ports. +func executePrepare( + t *testing.T, + env map[string]string, + args []string, + ports preparePorts, +) (string, string, error) { + t.Helper() + + if env == nil { + env = map[string]string{} + } + + stdout := &strings.Builder{} + stderr := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + value, ok := env[key] + return value, ok + }, + StateReader: ports.reader, + ContentPusher: ports.pusher, + Signer: ports.signer, + }) + command.SetArgs(args) + err := command.Execute() + + return stdout.String(), stderr.String(), err +} + +// executePrepareFactory runs publish oci prepare with observing factories. +func executePrepareFactory( + t *testing.T, + env map[string]string, + args []string, + factories prepareFactories, +) (string, error) { + t.Helper() + + if env == nil { + env = map[string]string{} + } + + stdout := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: &strings.Builder{}, + LookupEnv: func(key string) (string, bool) { + value, ok := env[key] + return value, ok + }, + NewStateReader: factories.newReader, + NewContentPusher: factories.newPusher, + NewSigner: factories.newSigner, + }) + command.SetArgs(args) + err := command.Execute() + + return stdout.String(), err +} + +// decodePrepareResult unmarshals the envelope result as [puboci.OCIPrepareResult]. +func decodePrepareResult(t *testing.T, stdout string) puboci.OCIPrepareResult { + t.Helper() + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result puboci.OCIPrepareResult + require.NoError(t, json.Unmarshal(raw, &result)) + + return result +} + +// assertPrepareFailureEnvelope checks stdout is one ok:false prepare envelope. +func assertPrepareFailureEnvelope(t *testing.T, stdout, wantError string) { + t.Helper() + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, prepareCommand, envelope.Command) + assert.False(t, envelope.OK) + + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.ErrorResult + require.NoError(t, json.Unmarshal(raw, &result)) + assert.Contains(t, result.Error, wantError) + assert.NotContains(t, stdout, tagsToken) +} + +// writeTwoPlatformLayout writes a linux/amd64 then linux/arm64 OCI layout. +func writeTwoPlatformLayout(t *testing.T) (string, puboci.Layout) { + t.Helper() + + amd64Config := prepareDescriptor(t, ocispec.MediaTypeImageConfig, []byte(prepareAMD64Config)) + arm64Config := prepareDescriptor(t, ocispec.MediaTypeImageConfig, []byte(prepareARM64Config)) + amd64Layer := prepareDescriptor(t, ocispec.MediaTypeImageLayer, []byte(prepareAMD64Layer)) + arm64Layer := prepareDescriptor(t, ocispec.MediaTypeImageLayer, []byte(prepareARM64Layer)) + amd64ManifestBytes := prepareManifestBytes(t, amd64Config, amd64Layer) + arm64ManifestBytes := prepareManifestBytes(t, arm64Config, arm64Layer) + amd64Manifest := prepareDescriptor(t, ocispec.MediaTypeImageManifest, amd64ManifestBytes) + arm64Manifest := prepareDescriptor(t, ocispec.MediaTypeImageManifest, arm64ManifestBytes) + indexBytes, err := json.Marshal(ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: prepareOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ + prepareOCIDescriptor(amd64Manifest, &ocispec.Platform{OS: "linux", Architecture: "amd64"}), + prepareOCIDescriptor(arm64Manifest, &ocispec.Platform{OS: "linux", Architecture: "arm64"}), + }, + }) + require.NoError(t, err) + + root := t.TempDir() + writeLayoutFile(t, root, "oci-layout", []byte(prepareLayoutJSON)) + writeLayoutFile(t, root, "index.json", indexBytes) + writeLayoutBlob(t, root, amd64Manifest.Digest, amd64ManifestBytes) + writeLayoutBlob(t, root, arm64Manifest.Digest, arm64ManifestBytes) + writeLayoutBlob(t, root, amd64Config.Digest, []byte(prepareAMD64Config)) + writeLayoutBlob(t, root, arm64Config.Digest, []byte(prepareARM64Config)) + writeLayoutBlob(t, root, amd64Layer.Digest, []byte(prepareAMD64Layer)) + writeLayoutBlob(t, root, arm64Layer.Digest, []byte(prepareARM64Layer)) + + layout, err := puboci.ReadLayout(os.DirFS(root)) + require.NoError(t, err) + + return root, layout +} + +// writeLayoutFile creates path under root with data. +func writeLayoutFile(t *testing.T, root, name string, data []byte) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(root, name), data, 0o644)) +} + +// writeLayoutBlob writes digest's blob bytes under the OCI layout root. +func writeLayoutBlob(t *testing.T, root string, digest rel.Digest, data []byte) { + t.Helper() + + name, err := puboci.BlobPath(digest) + require.NoError(t, err) + path := filepath.Join(root, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, data, 0o644)) +} + +// prepareManifestBytes marshals a one-layer image manifest. +func prepareManifestBytes(t *testing.T, config, layer puboci.Descriptor) []byte { + t.Helper() + + data, err := json.Marshal(ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: prepareOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageManifest, + Config: prepareOCIDescriptor(config, nil), + Layers: []ocispec.Descriptor{prepareOCIDescriptor(layer, nil)}, + }) + require.NoError(t, err) + + return data +} + +// prepareDescriptor returns the descriptor of data at mediaType. +func prepareDescriptor(t *testing.T, mediaType string, data []byte) puboci.Descriptor { + t.Helper() + + sum := sha256.Sum256(data) + digest, err := rel.ParseDigest("sha256:" + hex.EncodeToString(sum[:])) + require.NoError(t, err) + + return puboci.Descriptor{ + MediaType: mediaType, + Digest: digest, + Size: int64(len(data)), + } +} + +// prepareOCIDescriptor converts desc into an OCI descriptor with an optional platform. +func prepareOCIDescriptor(desc puboci.Descriptor, platform *ocispec.Platform) ocispec.Descriptor { + return ocispec.Descriptor{ + MediaType: desc.MediaType, + Digest: godigest.Digest(desc.Digest.String()), + Size: desc.Size, + Platform: platform, + } +} + +// mustImage parses the fixture image name. +func mustImage(t *testing.T) puboci.Image { + t.Helper() + + image, err := puboci.ParseImage(tagsImage) + require.NoError(t, err) + + return image +} diff --git a/internal/cli/root.go b/internal/cli/root.go index e9b3e1d..f796f75 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -27,6 +27,10 @@ const ( envImage = "RELEASE_IMAGE" // envVersion is the environment variable for --version. envVersion = "RELEASE_VERSION" + // envLayout is the environment variable for --layout. + envLayout = "RELEASE_LAYOUT" + // envDryRun is the environment variable for --dry-run. + envDryRun = "RELEASE_DRY_RUN" ) // LookupEnv looks up an environment variable. @@ -52,8 +56,16 @@ type Settings struct { Version string // Digest is the selected --digest / RELEASE_DIGEST value. Digest string + // Layout is the selected --layout / RELEASE_LAYOUT path. + Layout string + // DryRun reports whether --dry-run / RELEASE_DRY_RUN requested a dry run. + DryRun bool + // PlainHTTP reports whether --plain-http requested HTTP. + PlainHTTP bool // JSON reports whether --json / RELEASE_JSON requested structured output. JSON bool + // err is a flag or environment parse failure discovered while resolving settings. + err error } // BuildInfo describes linker-injected build metadata. @@ -66,6 +78,14 @@ type BuildInfo struct { Protocol int } +// RegistryConfig is the resolved registry client configuration. +type RegistryConfig struct { + // Credentials authenticates registry reads and writes. An empty password is anonymous. + Credentials RegistryCredentials + // PlainHTTP forces HTTP instead of HTTPS. Tests use this against a local registry. + PlainHTTP bool +} + // Options customizes root command construction. type Options struct { // In receives command input. @@ -84,8 +104,18 @@ type Options struct { NewArtifactMeta func(token string, endpoint GitHubEndpoint) (pubgh.ArtifactMeta, error) // StateReader, when set, is the registry read port. Tests inject it. StateReader puboci.StateReader - // NewStateReader constructs the registry read port from resolved credentials. - NewStateReader func(credentials RegistryCredentials) (puboci.StateReader, error) + // NewStateReader constructs the registry read port from resolved registry config. + NewStateReader func(config RegistryConfig) (puboci.StateReader, error) + // ContentPusher, when set, is the registry write port. Tests inject it. + ContentPusher puboci.ContentPusher + // NewContentPusher constructs the registry write port from resolved registry config. + NewContentPusher func(config RegistryConfig) (puboci.ContentPusher, error) + // Signer, when set, is the Cosign signing port. Tests inject it. + Signer puboci.Signer + // NewSigner constructs the Cosign signing port from a binary path. + // + // An empty path resolves cosign from PATH. + NewSigner func(path string) (puboci.Signer, error) // settings is filled after flags are parsed. settings *Settings } @@ -123,6 +153,7 @@ func NewRootCommand(options Options) *cobra.Command { root.AddCommand(newStageCommand(options)) root.AddCommand(newPlanCommand(options)) root.AddCommand(newVerifyCommand(options)) + root.AddCommand(newPublishCommand(options)) root.AddCommand(newVersionCommand(options)) return root @@ -167,15 +198,32 @@ func (options Options) withDefaults() Options { // resolveSettings applies flag-over-env precedence for the executing command. func resolveSettings(cmd *cobra.Command, lookup LookupEnv) Settings { - return Settings{ + settings := Settings{ Profile: resolveString(cmd, flagProfile, envProfile, lookup), Dist: resolveString(cmd, flagDist, envDist, lookup), ArtifactID: resolveString(cmd, flagArtifactID, envArtifactID, lookup), Image: resolveString(cmd, flagImage, envImage, lookup), Version: resolveString(cmd, flagVersion, envVersion, lookup), Digest: resolveString(cmd, flagDigest, envDigest, lookup), - JSON: resolveBool(cmd, "json", envJSON, lookup), + Layout: resolveString(cmd, flagLayout, envLayout, lookup), + PlainHTTP: resolveFlagBool(cmd, flagPlainHTTP), + } + dryRun, err := resolveBool(cmd, flagDryRun, envDryRun, lookup) + if err != nil { + settings.err = fmt.Errorf("%s: %w", envDryRun, err) + } else { + settings.DryRun = dryRun } + jsonOut, err := resolveBool(cmd, "json", envJSON, lookup) + if err != nil { + if settings.err == nil { + settings.err = fmt.Errorf("%s: %w", envJSON, err) + } + } else { + settings.JSON = jsonOut + } + + return settings } // resolveString returns the flag value when the flag was set, otherwise the @@ -196,17 +244,39 @@ func resolveString(cmd *cobra.Command, flagName, envName string, lookup LookupEn // resolveBool returns the flag value when the flag was set, otherwise the // named environment variable parsed as a bool, otherwise false. -func resolveBool(cmd *cobra.Command, flagName, envName string, lookup LookupEnv) bool { +// +// An unparsable environment value is an error. [strconv.ParseBool] does not +// accept yes, on, y, or enabled. +func resolveBool(cmd *cobra.Command, flagName, envName string, lookup LookupEnv) (bool, error) { if flag := cmd.Flags().Lookup(flagName); flag != nil && flag.Changed { value, err := strconv.ParseBool(flag.Value.String()) - return err == nil && value + if err != nil { + return false, err + } + + return value, nil } if raw, ok := lookup(envName); ok { value, err := strconv.ParseBool(raw) - return err == nil && value + if err != nil { + return false, err + } + + return value, nil + } + + return false, nil +} + +// resolveFlagBool returns the named flag when it was set, otherwise false. +func resolveFlagBool(cmd *cobra.Command, flagName string) bool { + flag := cmd.Flags().Lookup(flagName) + if flag == nil || !flag.Changed { + return false } + value, err := strconv.ParseBool(flag.Value.String()) - return false + return err == nil && value } // usageNoArgs rejects positional arguments as a usage error. diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 088a7e6..556ccbe 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -154,7 +154,7 @@ func TestStageUnknownFlagNoEnvelope(t *testing.T) { func TestUnknownCommandNoEnvelope(t *testing.T) { t.Parallel() - stdout, stderr, err := execute(t, nil, []string{"publish", "--json"}, cli.BuildInfo{}) + stdout, stderr, err := execute(t, nil, []string{"bogus", "--json"}, cli.BuildInfo{}) require.Error(t, err) assert.Equal(t, 2, cli.ExitCode(err)) assert.Empty(t, stdout) diff --git a/internal/cli/tags.go b/internal/cli/tags.go index 2622115..656c867 100644 --- a/internal/cli/tags.go +++ b/internal/cli/tags.go @@ -3,6 +3,7 @@ package cli import ( "errors" "fmt" + "net" "strings" "github.com/spf13/cobra" @@ -92,6 +93,7 @@ func newTagsCommand(options Options) *cobra.Command { cmd.Flags().String(flagImage, "", "OCI image name without a tag or digest") cmd.Flags().String(flagVersion, "", "stable MAJOR.MINOR.PATCH version") cmd.Flags().String(flagDigest, "", "candidate OCI index digest") + cmd.Flags().Bool(flagPlainHTTP, false, "use HTTP instead of HTTPS for the registry") return cmd } @@ -107,7 +109,7 @@ func runPlanTags(cmd *cobra.Command, options Options) error { return writeCommandResult(options, commandPlanTags, nil, UsageError(err)) } - reader, err := stateReader(options, expected.Credentials) + reader, err := stateReader(options, expected.Registry) if err != nil { return writeCommandResult(options, commandPlanTags, nil, err) } @@ -137,8 +139,8 @@ type tagsConfig struct { Version rel.Version // Digest is the candidate image digest. Digest rel.Digest - // Credentials authenticates registry reads. An empty password is anonymous. - Credentials RegistryCredentials + // Registry authenticates and configures the registry client. + Registry RegistryConfig } // resolveTags parses flags and Actions environment into a plan-tags config. @@ -149,6 +151,9 @@ func resolveTags(options Options) (tagsConfig, error) { if options.settings != nil { settings = *options.settings } + if err := settings.err; err != nil { + return tagsConfig{}, err + } digest, err := resolvePlanDigest(settings) if err != nil { @@ -158,16 +163,19 @@ func resolveTags(options Options) (tagsConfig, error) { if err != nil { return tagsConfig{}, err } + if plainErr := requireLoopbackPlainHTTP(image, settings.PlainHTTP); plainErr != nil { + return tagsConfig{}, plainErr + } version, err := resolvePlanVersion(settings, options.LookupEnv) if err != nil { return tagsConfig{}, err } return tagsConfig{ - Image: image, - Version: version, - Digest: digest, - Credentials: resolveRegistryCredentials(options.LookupEnv), + Image: image, + Version: version, + Digest: digest, + Registry: resolveRegistryConfig(settings, options.LookupEnv), }, nil } @@ -234,6 +242,33 @@ func deriveVersion(lookup LookupEnv) (string, error) { return strings.TrimPrefix(refName, "v"), nil } +// requireLoopbackPlainHTTP rejects --plain-http unless image is on loopback. +func requireLoopbackPlainHTTP(image puboci.Image, plainHTTP bool) error { + if !plainHTTP { + return nil + } + if loopbackImageHost(image) { + return nil + } + + return fmt.Errorf("--%s is allowed only for loopback image hosts", flagPlainHTTP) +} + +// loopbackImageHost reports whether image's registry host is loopback. +func loopbackImageHost(image puboci.Image) bool { + host, _, _ := strings.Cut(image.String(), "/") + hostname := host + if split, _, err := net.SplitHostPort(host); err == nil { + hostname = split + } + switch hostname { + case "127.0.0.1", "::1", "[::1]", "localhost": + return true + default: + return false + } +} + // resolveRegistryCredentials reads the optional Actions registry token. // // A missing token yields empty credentials and is not an error. @@ -256,8 +291,8 @@ func resolveRegistryCredentials(lookup LookupEnv) RegistryCredentials { } } -// stateReader returns the injected port or constructs one from credentials. -func stateReader(options Options, credentials RegistryCredentials) (puboci.StateReader, error) { +// stateReader returns the injected port or constructs one from registry config. +func stateReader(options Options, config RegistryConfig) (puboci.StateReader, error) { if options.StateReader != nil { return options.StateReader, nil } @@ -265,7 +300,7 @@ func stateReader(options Options, credentials RegistryCredentials) (puboci.State return nil, errors.New("state reader factory is not configured") } - reader, err := options.NewStateReader(credentials) + reader, err := options.NewStateReader(config) if err != nil { return nil, UsageError(fmt.Errorf("registry client: %w", err)) } diff --git a/internal/cli/tags_test.go b/internal/cli/tags_test.go index f569b83..8715079 100644 --- a/internal/cli/tags_test.go +++ b/internal/cli/tags_test.go @@ -30,7 +30,7 @@ func TestPlanTagsMissingDigestIsUsage(t *testing.T) { stdout, err := executeTagsFactory(t, map[string]string{ "RELEASE_IMAGE": tagsImage, "RELEASE_VERSION": "1.2.3", - }, []string{"plan", "tags"}, func(cli.RegistryCredentials) (puboci.StateReader, error) { + }, []string{"plan", "tags"}, func(cli.RegistryConfig) (puboci.StateReader, error) { called = true return unusedReader(t), nil }) @@ -87,7 +87,7 @@ func TestPlanTagsMalformedValuesAreUsage(t *testing.T) { t, tt.env, tt.args, - func(cli.RegistryCredentials) (puboci.StateReader, error) { + func(cli.RegistryConfig) (puboci.StateReader, error) { called = true return unusedReader(t), nil }, @@ -150,7 +150,7 @@ func TestPlanTagsJSONConfigFailure(t *testing.T) { t, nil, tt.args, - func(cli.RegistryCredentials) (puboci.StateReader, error) { + func(cli.RegistryConfig) (puboci.StateReader, error) { called = true return unusedReader(t), nil }, @@ -370,7 +370,7 @@ func TestPlanTagsCredentialResolution(t *testing.T) { t.Run("github token wins and actor is username", func(t *testing.T) { t.Parallel() - var got cli.RegistryCredentials + var got cli.RegistryConfig stdout, err := executeTagsFactory(t, map[string]string{ "GITHUB_TOKEN": tagsToken, "GH_TOKEN": "ghs_fallback_must_not_win", @@ -381,13 +381,14 @@ func TestPlanTagsCredentialResolution(t *testing.T) { "--version", "1.2.3", "--digest", tagsDigest, "--json", - }, func(credentials cli.RegistryCredentials) (puboci.StateReader, error) { - got = credentials + }, func(config cli.RegistryConfig) (puboci.StateReader, error) { + got = config return absentReader(t), nil }) require.NoError(t, err) - assert.Equal(t, "octocat", got.Username) - assert.Equal(t, tagsToken, got.Password.Reveal()) + assert.Equal(t, "octocat", got.Credentials.Username) + assert.Equal(t, tagsToken, got.Credentials.Password.Reveal()) + assert.False(t, got.PlainHTTP) assert.NotContains(t, stdout, tagsToken) assert.NotContains(t, stdout, "ghs_fallback_must_not_win") }) @@ -395,22 +396,83 @@ func TestPlanTagsCredentialResolution(t *testing.T) { t.Run("absent token is anonymous", func(t *testing.T) { t.Parallel() - var got cli.RegistryCredentials + var got cli.RegistryConfig called := false _, err := executeTagsFactory(t, nil, []string{ "plan", "tags", "--image", tagsImage, "--version", "1.2.3", "--digest", tagsDigest, - }, func(credentials cli.RegistryCredentials) (puboci.StateReader, error) { + }, func(config cli.RegistryConfig) (puboci.StateReader, error) { called = true - got = credentials + got = config return absentReader(t), nil }) require.NoError(t, err) require.True(t, called) - assert.Equal(t, cli.RegistryCredentials{}, got) - assert.True(t, got.Password.IsEmpty()) + assert.Equal(t, cli.RegistryConfig{}, got) + assert.True(t, got.Credentials.Password.IsEmpty()) + assert.False(t, got.PlainHTTP) + }) + + t.Run("plain http is refused for ghcr", func(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executeTagsFactory(t, nil, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + "--plain-http", + "--json", + }, func(cli.RegistryConfig) (puboci.StateReader, error) { + called = true + return unusedReader(t), nil + }) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.False(t, called) + assert.Contains(t, err.Error(), "--plain-http") + assert.Equal(t, 1, countJSONDocuments(stdout)) + }) + + t.Run("plain http is allowed for loopback", func(t *testing.T) { + t.Parallel() + + var got cli.RegistryConfig + _, err := executeTagsFactory(t, nil, []string{ + "plan", "tags", + "--image", "127.0.0.1:5000/owner/repo", + "--version", "1.2.3", + "--digest", tagsDigest, + "--plain-http", + }, func(config cli.RegistryConfig) (puboci.StateReader, error) { + got = config + return absentReader(t), nil + }) + require.NoError(t, err) + assert.True(t, got.PlainHTTP) + assert.True(t, got.Credentials.Password.IsEmpty()) + }) + + t.Run("plain http env is ignored", func(t *testing.T) { + t.Parallel() + + var got cli.RegistryConfig + _, err := executeTagsFactory(t, map[string]string{ + "RELEASE_PLAIN_HTTP": "true", + }, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + }, func(config cli.RegistryConfig) (puboci.StateReader, error) { + got = config + return absentReader(t), nil + }) + require.NoError(t, err) + assert.False(t, got.PlainHTTP) }) } @@ -543,7 +605,7 @@ func executeTagsFactory( t *testing.T, env map[string]string, args []string, - factory func(cli.RegistryCredentials) (puboci.StateReader, error), + factory func(cli.RegistryConfig) (puboci.StateReader, error), ) (string, error) { t.Helper() diff --git a/internal/stage/puboci/doc.go b/internal/stage/puboci/doc.go index 7d51e8e..3ec606f 100644 --- a/internal/stage/puboci/doc.go +++ b/internal/stage/puboci/doc.go @@ -1,6 +1,7 @@ -// Package puboci plans immutable exact tags and moving channel tags. +// Package puboci reads a local OCI layout and prepares digest-addressed publication. // -// [CollectState] reads current registry state through [StateReader]. -// [PlanTags] feeds that state to [rel.PlanTags]. The package performs no -// registry writes and does not retry transient failures. +// [ReadLayout] loads an extracted oci-image/layout directory. [Prepare] plans +// tags through [StateReader], pushes content through [ContentPusher], and +// signs the published index through [Signer]. Tagging is not part of this +// package yet. package puboci diff --git a/internal/stage/puboci/layout.go b/internal/stage/puboci/layout.go new file mode 100644 index 0000000..ef3ae3f --- /dev/null +++ b/internal/stage/puboci/layout.go @@ -0,0 +1,374 @@ +package puboci + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "path" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/meigma/release/internal/rel" +) + +const ( + // bytesPerKiB is the number of bytes in a kibibyte. + bytesPerKiB = 1024 + // kibibytesPerMiB is the number of kibibytes in a mebibyte. + kibibytesPerMiB = 1024 + // jsonLimitMiB is the JSON document size bound in mebibytes. + jsonLimitMiB = 4 + // jsonLimitBytes is the maximum encoded JSON document this package buffers. + // + // [ReadLayout] applies the bound to index.json and platform manifests. + // [ParsePrepareResult] applies it to a prepare-result document. Layer + // and config blobs are never read into memory. + jsonLimitBytes int64 = jsonLimitMiB * bytesPerKiB * kibibytesPerMiB + // ociSchemaVersion is the OCI image-spec schemaVersion for an index. + ociSchemaVersion = 2 + // layoutFileName is the OCI layout marker file. + layoutFileName = "oci-layout" + // indexFileName is the OCI image index document. + indexFileName = "index.json" + // blobDirectory is the OCI layout blob root. + blobDirectory = "blobs" + // blobAlgorithm is the only supported blob digest algorithm. + blobAlgorithm = "sha256" + // digestPrefix is the canonical sha256: digest prefix. + digestPrefix = blobAlgorithm + ":" +) + +// Platform is an OCI image OS and architecture pair. +type Platform struct { + // OS is the image operating system, such as linux. + OS string + // Architecture is the image CPU architecture, such as amd64. + Architecture string +} + +// PlatformImage is one platform manifest listed by an image index. +type PlatformImage struct { + // Descriptor is the index's descriptor for this platform manifest. + Descriptor Descriptor + // Platform is the OS and architecture recorded on that descriptor. + Platform Platform +} + +// Layout is a validated local OCI image layout rooted at oci-image/layout. +type Layout struct { + // Index is the descriptor of the exact index.json bytes. + Index Descriptor + // IndexBytes is the exact index.json contents, retained for push. + IndexBytes []byte + // Platforms are the index manifests in file order. + Platforms []PlatformImage + // Blobs are unique config and layer descriptors in first-seen push order. + Blobs []Descriptor +} + +// String returns os/architecture. +func (p Platform) String() string { + return p.OS + "/" + p.Architecture +} + +// ReadLayout loads an extracted oci-image/layout directory from fsys. +// +// fsys is a [fs.FS] rooted at the layout directory. A regular oci-layout +// file must exist. index.json is read verbatim; its descriptor digest is +// SHA-256 over those exact bytes and its size is their length. The index +// must use schemaVersion 2 and media type [ocispec.MediaTypeImageIndex] and +// must list at least one manifest. Each manifest descriptor is validated +// and must name a platform with a non-empty OS and architecture. Its blob +// must exist as a regular file of the declared size, and its config and +// layer blobs are collected the same way. Duplicate digests keep the +// first descriptor; a later descriptor with a different size or media +// type is an error. Layer and config blobs are never buffered. +// index.json and manifests are buffered up to [jsonLimitBytes]. +func ReadLayout(fsys fs.FS) (Layout, error) { + if fsys == nil { + return Layout{}, errors.New("layout filesystem is nil") + } + if err := requireRegularFile(fsys, layoutFileName); err != nil { + return Layout{}, err + } + + indexBytes, err := readJSONDocument(fsys, indexFileName) + if err != nil { + return Layout{}, err + } + index, indexDesc, err := parseIndex(indexBytes) + if err != nil { + return Layout{}, err + } + + blobs := newBlobCollector() + platforms := make([]PlatformImage, 0, len(index.Manifests)) + for i, desc := range index.Manifests { + platform, err := readPlatform(fsys, desc, blobs) + if err != nil { + return Layout{}, fmt.Errorf("%s manifests[%d]: %w", indexFileName, i, err) + } + platforms = append(platforms, platform) + } + + return Layout{ + Index: indexDesc, + IndexBytes: indexBytes, + Platforms: platforms, + Blobs: blobs.blobs, + }, nil +} + +// BlobPath returns the [fs.FS] slash path of digest under blobs/sha256. +// +// The digest must parse as sha256:<64 hex>. The result is valid for +// [fs.ValidPath]. +func BlobPath(digest rel.Digest) (string, error) { + parsed, err := rel.ParseDigest(digest.String()) + if err != nil { + return "", err + } + + hexPart, found := strings.CutPrefix(parsed.String(), digestPrefix) + if !found || hexPart == "" { + return "", fmt.Errorf("digest %q is missing hex", parsed) + } + + name := path.Join(blobDirectory, blobAlgorithm, hexPart) + if !fs.ValidPath(name) { + return "", fmt.Errorf("blob path %q is invalid", name) + } + + return name, nil +} + +// blobCollector records unique config and layer descriptors in first-seen order. +type blobCollector struct { + // blobs is the push-order list of unique descriptors. + blobs []Descriptor + // seen maps digest to the first descriptor recorded for it. + seen map[rel.Digest]Descriptor +} + +// newBlobCollector constructs an empty first-seen blob list. +func newBlobCollector() *blobCollector { + return &blobCollector{seen: make(map[rel.Digest]Descriptor)} +} + +// parseIndex decodes and validates index.json bytes without rewriting them. +func parseIndex(indexBytes []byte) (ocispec.Index, Descriptor, error) { + var index ocispec.Index + if err := json.Unmarshal(indexBytes, &index); err != nil { + return ocispec.Index{}, Descriptor{}, fmt.Errorf("%s: %w", indexFileName, err) + } + if index.SchemaVersion != ociSchemaVersion { + return ocispec.Index{}, Descriptor{}, fmt.Errorf( + "%s schemaVersion is %d, want %d", + indexFileName, + index.SchemaVersion, + ociSchemaVersion, + ) + } + if index.MediaType != ocispec.MediaTypeImageIndex { + return ocispec.Index{}, Descriptor{}, fmt.Errorf( + "%s mediaType is %q, want %q", + indexFileName, + index.MediaType, + ocispec.MediaTypeImageIndex, + ) + } + if len(index.Manifests) == 0 { + return ocispec.Index{}, Descriptor{}, fmt.Errorf("%s has no manifests", indexFileName) + } + + digest, err := digestBytes(indexBytes) + if err != nil { + return ocispec.Index{}, Descriptor{}, err + } + + return index, Descriptor{ + MediaType: ocispec.MediaTypeImageIndex, + Digest: digest, + Size: int64(len(indexBytes)), + }, nil +} + +// readPlatform validates one index manifest descriptor and collects its blobs. +func readPlatform(fsys fs.FS, raw ocispec.Descriptor, blobs *blobCollector) (PlatformImage, error) { + desc, err := descriptorFromOCI(raw) + if err != nil { + return PlatformImage{}, err + } + platform, err := requiredPlatform(raw.Platform) + if err != nil { + return PlatformImage{}, err + } + if blobErr := requireBlob(fsys, desc); blobErr != nil { + return PlatformImage{}, blobErr + } + + name, err := BlobPath(desc.Digest) + if err != nil { + return PlatformImage{}, err + } + body, err := readJSONDocument(fsys, name) + if err != nil { + return PlatformImage{}, err + } + + var manifest ocispec.Manifest + if err := json.Unmarshal(body, &manifest); err != nil { + return PlatformImage{}, fmt.Errorf("%s: %w", name, err) + } + if err := collectBlobs(fsys, manifest, blobs); err != nil { + return PlatformImage{}, err + } + + return PlatformImage{Descriptor: desc, Platform: platform}, nil +} + +// collectBlobs records a manifest's config and layer descriptors in push order. +func collectBlobs(fsys fs.FS, manifest ocispec.Manifest, blobs *blobCollector) error { + config, err := descriptorFromOCI(manifest.Config) + if err != nil { + return fmt.Errorf("config: %w", err) + } + if err := addUniqueBlob(fsys, blobs, config); err != nil { + return fmt.Errorf("config: %w", err) + } + + for i, layer := range manifest.Layers { + desc, err := descriptorFromOCI(layer) + if err != nil { + return fmt.Errorf("layers[%d]: %w", i, err) + } + if err := addUniqueBlob(fsys, blobs, desc); err != nil { + return fmt.Errorf("layers[%d]: %w", i, err) + } + } + + return nil +} + +// addUniqueBlob records desc if new and requires its blob file on first sight. +func addUniqueBlob(fsys fs.FS, blobs *blobCollector, desc Descriptor) error { + if existing, ok := blobs.seen[desc.Digest]; ok { + if existing.Size != desc.Size || existing.MediaType != desc.MediaType { + return fmt.Errorf("digest %s has conflicting descriptors", desc.Digest) + } + + return nil + } + if err := requireBlob(fsys, desc); err != nil { + return err + } + blobs.seen[desc.Digest] = desc + blobs.blobs = append(blobs.blobs, desc) + + return nil +} + +// requireBlob requires digest's blob file to be regular and of desc.Size. +func requireBlob(fsys fs.FS, desc Descriptor) error { + name, err := BlobPath(desc.Digest) + if err != nil { + return err + } + info, err := fs.Stat(fsys, name) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", name) + } + if info.Size() != desc.Size { + return fmt.Errorf("%s is %d bytes, declared %d", name, info.Size(), desc.Size) + } + + return nil +} + +// requireRegularFile requires name to exist as a regular file. +func requireRegularFile(fsys fs.FS, name string) error { + info, err := fs.Stat(fsys, name) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", name) + } + + return nil +} + +// readJSONDocument reads a regular JSON file of at most [jsonLimitBytes]. +func readJSONDocument(fsys fs.FS, name string) ([]byte, error) { + info, err := fs.Stat(fsys, name) + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", name) + } + if info.Size() > jsonLimitBytes { + return nil, fmt.Errorf("%s is %d bytes, exceeds the %d byte JSON limit", name, info.Size(), jsonLimitBytes) + } + + file, err := fsys.Open(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, jsonLimitBytes)) + if err != nil { + return nil, fmt.Errorf("read %s: %w", name, err) + } + if int64(len(data)) != info.Size() { + return nil, fmt.Errorf("%s: short read", name) + } + + return data, nil +} + +// descriptorFromOCI converts an OCI descriptor after validating it. +func descriptorFromOCI(desc ocispec.Descriptor) (Descriptor, error) { + digest, err := rel.ParseDigest(desc.Digest.String()) + if err != nil { + return Descriptor{}, err + } + + out := Descriptor{MediaType: desc.MediaType, Digest: digest, Size: desc.Size} + if err := out.Validate(); err != nil { + return Descriptor{}, err + } + + return out, nil +} + +// requiredPlatform copies a required OCI platform into a [Platform]. +func requiredPlatform(platform *ocispec.Platform) (Platform, error) { + if platform == nil { + return Platform{}, errors.New("platform is missing") + } + if platform.OS == "" { + return Platform{}, errors.New("platform os is empty") + } + if platform.Architecture == "" { + return Platform{}, errors.New("platform architecture is empty") + } + + return Platform{OS: platform.OS, Architecture: platform.Architecture}, nil +} + +// digestBytes returns the sha256 digest of data. +func digestBytes(data []byte) (rel.Digest, error) { + sum := sha256.Sum256(data) + + return rel.ParseDigest(digestPrefix + hex.EncodeToString(sum[:])) +} diff --git a/internal/stage/puboci/layout_test.go b/internal/stage/puboci/layout_test.go new file mode 100644 index 0000000..745e875 --- /dev/null +++ b/internal/stage/puboci/layout_test.go @@ -0,0 +1,549 @@ +package puboci_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io/fs" + "testing" + "testing/fstest" + + godigest "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // testOCISchemaVersion is the OCI image-spec schemaVersion used in fixtures. + testOCISchemaVersion = 2 + // testLayoutJSON is a valid oci-layout marker document. + testLayoutJSON = `{"imageLayoutVersion":"1.0.0"}` + // testAMD64Config is a distinct amd64 config blob. + testAMD64Config = `{"architecture":"amd64","os":"linux"}` + // testARM64Config is a distinct arm64 config blob. + testARM64Config = `{"architecture":"arm64","os":"linux"}` + // testAMD64Layer is a distinct amd64 layer blob. + testAMD64Layer = "amd64-layer" + // testARM64Layer is a distinct arm64 layer blob. + testARM64Layer = "arm64-layer" + // testSharedLayer is a layer blob referenced by both platforms. + testSharedLayer = "shared-layer" + // testBytesPerKiB is the number of bytes in a kibibyte. + testBytesPerKiB = 1024 + // testKibibytesPerMiB is the number of kibibytes in a mebibyte. + testKibibytesPerMiB = 1024 + // testJSONLimitMiB matches the package JSON document bound. + testJSONLimitMiB = 4 + // testJSONLimitBytes is the maximum encoded JSON document ReadLayout buffers. + testJSONLimitBytes int64 = testJSONLimitMiB * testBytesPerKiB * testKibibytesPerMiB + // testOverJSONLimitBytes is one byte past the JSON document bound. + testOverJSONLimitBytes = testJSONLimitBytes + 1 +) + +func TestReadLayoutTwoPlatforms(t *testing.T) { + t.Parallel() + + fixture := newTwoPlatformLayout(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + + got, err := puboci.ReadLayout(fixture.files) + require.NoError(t, err) + + assert.Equal(t, ocispec.MediaTypeImageIndex, got.Index.MediaType) + assert.Equal(t, digestOf(t, fixture.indexBytes), got.Index.Digest) + assert.Equal(t, int64(len(fixture.indexBytes)), got.Index.Size) + assert.Equal(t, fixture.indexBytes, got.IndexBytes) + assert.Equal(t, []puboci.PlatformImage{fixture.amd64, fixture.arm64}, got.Platforms) + assert.Equal(t, fixture.blobs, got.Blobs) +} + +func TestReadLayoutSharedLayer(t *testing.T) { + t.Parallel() + + shared := []byte(testSharedLayer) + fixture := newTwoPlatformLayout(t, shared, shared) + + got, err := puboci.ReadLayout(fixture.files) + require.NoError(t, err) + + assert.Equal(t, fixture.blobs, got.Blobs) + require.Len(t, got.Blobs, 3) + assert.Equal(t, digestOf(t, shared), got.Blobs[1].Digest) +} + +func TestReadLayoutErrors(t *testing.T) { + t.Parallel() + + valid := newTwoPlatformLayout(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + manifest := descriptorFor(t, ocispec.MediaTypeImageManifest, []byte(`{"schemaVersion":2}`)) + missing := puboci.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: mustDigest(t, validDigest), + Size: 1, + } + oversized := manifest + oversized.Size++ + + tests := []struct { + name string + files fs.FS + wantErr string + }{ + { + name: "missing oci-layout", + files: fstest.MapFS{indexFileName(): {Data: valid.indexBytes}}, + wantErr: "oci-layout", + }, + { + name: "missing index.json", + files: fstest.MapFS{layoutFileName(): {Data: []byte(testLayoutJSON)}}, + wantErr: "index.json", + }, + { + name: "malformed JSON", + files: fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: []byte("{")}, + }, + wantErr: "index.json", + }, + { + name: "wrong schemaVersion", + files: fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: encodeIndex(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 1}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ociDescriptor(manifest, linuxAMD64())}, + })}, + }, + wantErr: "schemaVersion is 1, want 2", + }, + { + name: "wrong index media type", + files: fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: encodeIndex(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: testOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageManifest, + Manifests: []ocispec.Descriptor{ociDescriptor(manifest, linuxAMD64())}, + })}, + }, + wantErr: "mediaType is \"" + ocispec.MediaTypeImageManifest + "\"", + }, + { + name: "zero manifests", + files: fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: encodeIndex(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: testOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{}, + })}, + }, + wantErr: "has no manifests", + }, + { + name: "missing manifest blob", + files: fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: encodeIndex(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: testOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ociDescriptor(missing, linuxAMD64())}, + })}, + }, + wantErr: blobFile(t, missing.Digest), + }, + { + name: "declared size differs", + files: fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): { + Data: encodeIndexFrom(t, indexEntry{desc: oversized, platform: linuxAMD64()}), + }, + blobFile(t, manifest.Digest): {Data: []byte(`{"schemaVersion":2}`)}, + }, + wantErr: "declared", + }, + { + name: "conflicting blob size", + files: conflictingLayerLayout(t), + wantErr: "conflicting descriptors", + }, + { + name: "missing platform", + files: missingPlatformLayout(t, manifest), + wantErr: "platform is missing", + }, + { + name: "oversized index.json", + files: oversizedIndexLayout(t), + wantErr: "exceeds the", + }, + { + name: "oversized platform manifest", + files: oversizedManifestLayout(t, manifest), + wantErr: "exceeds the", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := puboci.ReadLayout(test.files) + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestBlobPath(t *testing.T) { + t.Parallel() + + digest := mustDigest(t, validDigest) + got, err := puboci.BlobPath(digest) + require.NoError(t, err) + assert.Equal(t, "blobs/sha256/"+validHex(), got) + assert.True(t, fs.ValidPath(got)) + + _, err = puboci.BlobPath("") + require.Error(t, err) + assert.Contains(t, err.Error(), "digest") +} + +// layoutFixture is a two-platform OCI layout and the descriptors it contains. +type layoutFixture struct { + // files is the extracted layout directory. + files fstest.MapFS + // indexBytes is the exact index.json contents. + indexBytes []byte + // amd64 is the first platform listed by the index. + amd64 puboci.PlatformImage + // arm64 is the second platform listed by the index. + arm64 puboci.PlatformImage + // blobs is the expected first-seen config and layer push order. + blobs []puboci.Descriptor +} + +// newTwoPlatformLayout builds linux/amd64 then linux/arm64 with the given layers. +func newTwoPlatformLayout(t *testing.T, amd64Layer, arm64Layer []byte) layoutFixture { + t.Helper() + + amd64Config := descriptorFor(t, ocispec.MediaTypeImageConfig, []byte(testAMD64Config)) + arm64Config := descriptorFor(t, ocispec.MediaTypeImageConfig, []byte(testARM64Config)) + amd64LayerDesc := descriptorFor(t, ocispec.MediaTypeImageLayer, amd64Layer) + arm64LayerDesc := descriptorFor(t, ocispec.MediaTypeImageLayer, arm64Layer) + amd64Manifest := manifestFor(amd64Config, amd64LayerDesc) + arm64Manifest := manifestFor(arm64Config, arm64LayerDesc) + amd64ManifestBytes := encodeManifest(t, amd64Manifest) + arm64ManifestBytes := encodeManifest(t, arm64Manifest) + amd64Image := puboci.PlatformImage{ + Descriptor: descriptorFor(t, ocispec.MediaTypeImageManifest, amd64ManifestBytes), + Platform: puboci.Platform{OS: "linux", Architecture: "amd64"}, + } + arm64Image := puboci.PlatformImage{ + Descriptor: descriptorFor(t, ocispec.MediaTypeImageManifest, arm64ManifestBytes), + Platform: puboci.Platform{OS: "linux", Architecture: "arm64"}, + } + indexBytes := encodeIndexFrom(t, + indexEntry{desc: amd64Image.Descriptor, platform: linuxAMD64()}, + indexEntry{desc: arm64Image.Descriptor, platform: linuxARM64()}, + ) + + files := fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: indexBytes}, + blobFile(t, amd64Image.Descriptor.Digest): {Data: amd64ManifestBytes}, + blobFile(t, arm64Image.Descriptor.Digest): {Data: arm64ManifestBytes}, + blobFile(t, amd64Config.Digest): {Data: []byte(testAMD64Config)}, + blobFile(t, arm64Config.Digest): {Data: []byte(testARM64Config)}, + blobFile(t, amd64LayerDesc.Digest): {Data: amd64Layer}, + } + if amd64LayerDesc.Digest != arm64LayerDesc.Digest { + files[blobFile(t, arm64LayerDesc.Digest)] = &fstest.MapFile{Data: arm64Layer} + } + + blobs := []puboci.Descriptor{amd64Config, amd64LayerDesc} + if arm64Config.Digest != amd64Config.Digest { + blobs = append(blobs, arm64Config) + } + if arm64LayerDesc.Digest != amd64LayerDesc.Digest { + blobs = append(blobs, arm64LayerDesc) + } + + return layoutFixture{ + files: files, + indexBytes: indexBytes, + amd64: amd64Image, + arm64: arm64Image, + blobs: blobs, + } +} + +// conflictingLayerLayout is a two-platform layout whose shared layer digest +// is declared with two different sizes. +func conflictingLayerLayout(t *testing.T) fstest.MapFS { + t.Helper() + + shared := []byte(testSharedLayer) + amd64Config := descriptorFor(t, ocispec.MediaTypeImageConfig, []byte(testAMD64Config)) + arm64Config := descriptorFor(t, ocispec.MediaTypeImageConfig, []byte(testARM64Config)) + sharedLayer := descriptorFor(t, ocispec.MediaTypeImageLayer, shared) + conflictLayer := sharedLayer + conflictLayer.Size++ + amd64ManifestBytes := encodeManifest(t, manifestFor(amd64Config, sharedLayer)) + arm64ManifestBytes := encodeManifest(t, manifestFor(arm64Config, conflictLayer)) + amd64Manifest := descriptorFor(t, ocispec.MediaTypeImageManifest, amd64ManifestBytes) + arm64Manifest := descriptorFor(t, ocispec.MediaTypeImageManifest, arm64ManifestBytes) + + return fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: encodeIndexFrom(t, + indexEntry{desc: amd64Manifest, platform: linuxAMD64()}, + indexEntry{desc: arm64Manifest, platform: linuxARM64()}, + )}, + blobFile(t, amd64Manifest.Digest): {Data: amd64ManifestBytes}, + blobFile(t, arm64Manifest.Digest): {Data: arm64ManifestBytes}, + blobFile(t, amd64Config.Digest): {Data: []byte(testAMD64Config)}, + blobFile(t, arm64Config.Digest): {Data: []byte(testARM64Config)}, + blobFile(t, sharedLayer.Digest): {Data: shared}, + } +} + +// missingPlatformLayout is an index whose only manifest has no platform. +func missingPlatformLayout(t *testing.T, manifest puboci.Descriptor) fstest.MapFS { + t.Helper() + + return fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: encodeIndex(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: testOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ociDescriptor(manifest, nil)}, + })}, + } +} + +// oversizedIndexLayout reports an index.json larger than the JSON bound. +func oversizedIndexLayout(t *testing.T) fs.FS { + t.Helper() + + return withReportedSize(fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): {Data: []byte(`{}`)}, + }, indexFileName(), testOverJSONLimitBytes) +} + +// oversizedManifestLayout points a valid index at a too-large platform manifest. +func oversizedManifestLayout(t *testing.T, manifest puboci.Descriptor) fs.FS { + t.Helper() + + desc := manifest + desc.Size = testOverJSONLimitBytes + + return withReportedSize(fstest.MapFS{ + layoutFileName(): {Data: []byte(testLayoutJSON)}, + indexFileName(): { + Data: encodeIndexFrom(t, indexEntry{desc: desc, platform: linuxAMD64()}), + }, + blobFile(t, desc.Digest): {Data: []byte(`{"schemaVersion":2}`)}, + }, blobFile(t, desc.Digest), testOverJSONLimitBytes) +} + +// withReportedSize reports size for name without allocating that many bytes. +func withReportedSize(base fstest.MapFS, name string, size int64) fs.FS { + return sizedFS{MapFS: base, sizes: map[string]int64{name: size}} +} + +// sizedFS is a MapFS that lies about selected file sizes. +type sizedFS struct { + // MapFS is the underlying layout files. + fstest.MapFS + + // sizes overrides Stat size for named files. + sizes map[string]int64 +} + +// Open returns a file whose Stat reports any overridden size. +func (s sizedFS) Open(name string) (fs.File, error) { + file, err := s.MapFS.Open(name) + if err != nil { + return nil, err + } + if size, ok := s.sizes[name]; ok { + return sizedFile{File: file, size: size}, nil + } + + return file, nil +} + +// Stat reports any overridden size for name. +func (s sizedFS) Stat(name string) (fs.FileInfo, error) { + info, err := s.MapFS.Stat(name) + if err != nil { + return nil, err + } + if size, ok := s.sizes[name]; ok { + return sizedInfo{FileInfo: info, size: size}, nil + } + + return info, nil +} + +// sizedFile is a file whose Stat reports a fixed size. +type sizedFile struct { + // File is the opened underlying file. + fs.File + + // size is the reported content length. + size int64 +} + +// Stat returns the overridden size. +func (f sizedFile) Stat() (fs.FileInfo, error) { + info, err := f.File.Stat() + if err != nil { + return nil, err + } + + return sizedInfo{FileInfo: info, size: f.size}, nil +} + +// sizedInfo is a FileInfo with a replaced Size. +type sizedInfo struct { + // FileInfo is the underlying file metadata. + fs.FileInfo + + // size is the reported content length. + size int64 +} + +// Size returns the overridden content length. +func (i sizedInfo) Size() int64 { + return i.size +} + +// indexEntry is one index manifest descriptor and its platform. +type indexEntry struct { + // desc is the platform manifest descriptor. + desc puboci.Descriptor + // platform is the OS and architecture recorded on that descriptor. + platform *ocispec.Platform +} + +// encodeIndexFrom marshals an index listing the given platform manifests. +func encodeIndexFrom(t *testing.T, entries ...indexEntry) []byte { + t.Helper() + + manifests := make([]ocispec.Descriptor, 0, len(entries)) + for _, entry := range entries { + manifests = append(manifests, ociDescriptor(entry.desc, entry.platform)) + } + + return encodeIndex(t, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: testOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: manifests, + }) +} + +// encodeIndex marshals index as JSON. +func encodeIndex(t *testing.T, index ocispec.Index) []byte { + t.Helper() + + data, err := json.Marshal(index) + require.NoError(t, err) + + return data +} + +// encodeManifest marshals manifest as JSON. +func encodeManifest(t *testing.T, manifest ocispec.Manifest) []byte { + t.Helper() + + data, err := json.Marshal(manifest) + require.NoError(t, err) + + return data +} + +// manifestFor returns a one-layer image manifest. +func manifestFor(config puboci.Descriptor, layer puboci.Descriptor) ocispec.Manifest { + return ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: testOCISchemaVersion}, + MediaType: ocispec.MediaTypeImageManifest, + Config: ociDescriptor(config, nil), + Layers: []ocispec.Descriptor{ociDescriptor(layer, nil)}, + } +} + +// ociDescriptor converts desc into an OCI descriptor with an optional platform. +func ociDescriptor(desc puboci.Descriptor, platform *ocispec.Platform) ocispec.Descriptor { + return ocispec.Descriptor{ + MediaType: desc.MediaType, + Digest: godigest.Digest(desc.Digest.String()), + Size: desc.Size, + Platform: platform, + } +} + +// descriptorFor returns the descriptor of data at mediaType. +func descriptorFor(t *testing.T, mediaType string, data []byte) puboci.Descriptor { + t.Helper() + + return puboci.Descriptor{ + MediaType: mediaType, + Digest: digestOf(t, data), + Size: int64(len(data)), + } +} + +// digestOf returns the sha256 digest of data. +func digestOf(t *testing.T, data []byte) rel.Digest { + t.Helper() + + sum := sha256.Sum256(data) + + return mustDigest(t, "sha256:"+hex.EncodeToString(sum[:])) +} + +// blobFile returns the layout path of digest. +func blobFile(t *testing.T, digest rel.Digest) string { + t.Helper() + + name, err := puboci.BlobPath(digest) + require.NoError(t, err) + + return name +} + +// linuxAMD64 is the linux/amd64 platform. +func linuxAMD64() *ocispec.Platform { + return &ocispec.Platform{OS: "linux", Architecture: "amd64"} +} + +// linuxARM64 is the linux/arm64 platform. +func linuxARM64() *ocispec.Platform { + return &ocispec.Platform{OS: "linux", Architecture: "arm64"} +} + +// layoutFileName is the OCI layout marker path. +func layoutFileName() string { + return "oci-layout" +} + +// indexFileName is the OCI index path. +func indexFileName() string { + return "index.json" +} + +// validHex is the hex part of validDigest. +func validHex() string { + return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/internal/stage/puboci/ports.go b/internal/stage/puboci/ports.go new file mode 100644 index 0000000..f3fb20d --- /dev/null +++ b/internal/stage/puboci/ports.go @@ -0,0 +1,51 @@ +package puboci + +import ( + "context" + "io" +) + +// ContentPusher writes digest-addressed OCI content and checks that it resolves. +// +// Pushes address content by digest and are idempotent: an already-present blob +// or manifest is success, not an error. Implementations stream [io.Reader] +// content and must not require the caller to buffer a layer. [ContentPusher.Verify] +// resolves [DigestRef] and fails unless the registry returns that same digest. +// Absent content is classified as [ErrTagAbsent]. Transient failures are +// classified as [ErrRetryable] and are not retried here. +type ContentPusher interface { + // PushBlob uploads one blob addressed by descriptor.Digest. + // + // An already-present blob is success. content is the blob bytes and is + // consumed at most once. Callers never pass a layer that has been + // buffered into memory. + PushBlob(ctx context.Context, image Image, descriptor Descriptor, content io.Reader) error + + // PushManifest uploads one manifest or index addressed by descriptor.Digest. + // + // The registry stores content under descriptor.MediaType. An + // already-present manifest is success. content is the exact encoded + // document and is consumed at most once. + PushManifest(ctx context.Context, image Image, descriptor Descriptor, content io.Reader) error + + // Verify resolves ref and requires the registry digest to equal ref.Digest. + // + // Missing content wraps [ErrTagAbsent]. Transient registry failures + // wrap [ErrRetryable]. A different resolved digest is a verification + // failure and is not classified as absent. + Verify(ctx context.Context, ref DigestRef) error +} + +// Signer attaches signatures to a published image index. +// +// [Signer.SignRecursive] signs the index at ref and every manifest that +// index references. Implementations invoke `cosign sign --yes --recursive` +// against image@digest. Sign failures are returned as received; this port +// does not classify them as [ErrRetryable]. +type Signer interface { + // SignRecursive signs ref and every referenced platform manifest. + // + // ref is the published index. The call writes signatures only; it does + // not mutate tags. + SignRecursive(ctx context.Context, ref DigestRef) error +} diff --git a/internal/stage/puboci/prepare.go b/internal/stage/puboci/prepare.go new file mode 100644 index 0000000..e303fb1 --- /dev/null +++ b/internal/stage/puboci/prepare.go @@ -0,0 +1,310 @@ +package puboci + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "time" + + "github.com/meigma/release/internal/rel" +) + +const ( + // retryAttempts is one initial publication call plus three retries. + retryAttempts = 4 + // retryWait is the first backoff; later waits double (1s, 2s, 4s). + retryWait = time.Second +) + +// SleepFunc waits for d or until ctx is cancelled. +type SleepFunc func(ctx context.Context, d time.Duration) error + +// PrepareInput is the candidate image, layout, and expected index digest. +type PrepareInput struct { + // Image is the untagged repository that will receive the content. + Image Image + // Version is the candidate MAJOR.MINOR.PATCH release. + Version rel.Version + // IndexDigest is the expected index digest, cross-checked against the layout. + IndexDigest rel.Digest + // Layout is a filesystem rooted at the extracted oci-image/layout directory. + Layout fs.FS + // DryRun skips every write, verification, and signature. + DryRun bool + // Sleep waits between retryable publication attempts. Nil selects a + // context-aware timer. + Sleep SleepFunc +} + +// Prepare reads a local OCI layout, plans tags, and publishes digest-addressed content. +// +// It fails closed before any write when the layout index digest does not match +// [PrepareInput.IndexDigest], when [CollectState] fails, or when [rel.PlanTags] +// reports an immutable-tag or corrupt-channel conflict. A dry run returns +// [NewPrepareResult] with Authoritative false and never calls pusher or signer; +// those ports may be nil only in that mode. A real prepare pushes every layout +// blob, then each platform manifest, then the index, verifies the index and +// every platform digest (a deliberate strengthening of the workflow, which +// verifies only the index), and signs the index recursively. Each push and +// verification is attempted at most four times. Failures wrapping +// [ErrRetryable] wait 1s, then 2s, then 4s, and reopen the layout blob so the +// stream starts at byte zero. Other errors fail immediately. Context +// cancellation returns immediately. A nil [PrepareInput.Sleep] uses a +// context-aware timer. Errors name the failing step and descriptor digest and +// wrap the underlying error. +func Prepare( + ctx context.Context, + input PrepareInput, + state StateReader, + pusher ContentPusher, + signer Signer, +) (OCIPrepareResult, error) { + if err := validatePrepare(ctx, input, state); err != nil { + return OCIPrepareResult{}, err + } + + layout, err := ReadLayout(input.Layout) + if err != nil { + return OCIPrepareResult{}, fmt.Errorf("read layout: %w", err) + } + if layout.Index.Digest != input.IndexDigest { + return OCIPrepareResult{}, fmt.Errorf( + "layout index digest %s does not match expected %s", + layout.Index.Digest, + input.IndexDigest, + ) + } + + current, err := CollectState(ctx, state, input.Image, input.Version, input.IndexDigest) + if err != nil { + return OCIPrepareResult{}, fmt.Errorf("collect state: %w", err) + } + if _, err := rel.PlanTags(input.Version, input.IndexDigest, current); err != nil { + return OCIPrepareResult{}, fmt.Errorf("plan tags: %w", err) + } + + if input.DryRun { + return NewPrepareResult( + input.Image, + input.Version, + input.IndexDigest, + layout.Platforms, + current, + false, + ), nil + } + if pusher == nil { + return OCIPrepareResult{}, errors.New("content pusher is nil") + } + if signer == nil { + return OCIPrepareResult{}, errors.New("signer is nil") + } + + sleep := input.Sleep + if sleep == nil { + sleep = sleepContext + } + if err := pushContent(ctx, input.Image, input.Layout, layout, pusher, sleep); err != nil { + return OCIPrepareResult{}, err + } + if err := verifyContent(ctx, input.Image, layout, pusher, sleep); err != nil { + return OCIPrepareResult{}, err + } + + indexRef := input.Image.Pin(input.IndexDigest) + if err := signer.SignRecursive(ctx, indexRef); err != nil { + return OCIPrepareResult{}, fmt.Errorf("sign %s: %w", indexRef, err) + } + + return NewPrepareResult( + input.Image, + input.Version, + input.IndexDigest, + layout.Platforms, + current, + true, + ), nil +} + +// validatePrepare rejects a nil context, a nil layout, a zero image, version, +// or digest, and a nil state reader. +func validatePrepare(ctx context.Context, input PrepareInput, state StateReader) error { + if ctx == nil { + return errors.New("context is nil") + } + if input.Layout == nil { + return errors.New("layout is nil") + } + if input.Image == "" { + return errors.New("image is empty") + } + if input.Version == (rel.Version{}) { + return errors.New("version is zero") + } + if input.IndexDigest == "" { + return errors.New("digest is empty") + } + if state == nil { + return errors.New("state reader is nil") + } + + return nil +} + +// pushContent uploads blobs, platform manifests, then the index, in that order. +func pushContent( + ctx context.Context, + image Image, + fsys fs.FS, + layout Layout, + pusher ContentPusher, + sleep SleepFunc, +) error { + for _, blob := range layout.Blobs { + if err := pushBlob(ctx, image, fsys, blob, pusher, sleep); err != nil { + return err + } + } + for _, platform := range layout.Platforms { + if err := pushManifest(ctx, image, fsys, platform.Descriptor, pusher, sleep); err != nil { + return err + } + } + + return pushIndex(ctx, image, layout, pusher, sleep) +} + +// pushBlob streams one config or layer blob from the layout filesystem. +func pushBlob( + ctx context.Context, + image Image, + fsys fs.FS, + desc Descriptor, + pusher ContentPusher, + sleep SleepFunc, +) error { + return withRetry(ctx, sleep, "push blob", desc.Digest, func() error { + file, err := openLayoutBlob(fsys, desc.Digest) + if err != nil { + return err + } + err = pusher.PushBlob(ctx, image, desc, file) + closeErr := file.Close() + if err != nil { + return err + } + + return closeErr + }) +} + +// pushManifest streams one platform manifest from the layout filesystem. +func pushManifest( + ctx context.Context, + image Image, + fsys fs.FS, + desc Descriptor, + pusher ContentPusher, + sleep SleepFunc, +) error { + return withRetry(ctx, sleep, "push manifest", desc.Digest, func() error { + file, err := openLayoutBlob(fsys, desc.Digest) + if err != nil { + return err + } + err = pusher.PushManifest(ctx, image, desc, file) + closeErr := file.Close() + if err != nil { + return err + } + + return closeErr + }) +} + +// pushIndex uploads the exact index.json bytes retained by [ReadLayout]. +func pushIndex(ctx context.Context, image Image, layout Layout, pusher ContentPusher, sleep SleepFunc) error { + return withRetry(ctx, sleep, "push index", layout.Index.Digest, func() error { + return pusher.PushManifest(ctx, image, layout.Index, bytes.NewReader(layout.IndexBytes)) + }) +} + +// verifyContent requires the published index, then each platform, to resolve. +// +// The publish workflow verifies only the index. Checking every platform +// manifest digest as well is a deliberate strengthening so a partial push +// cannot look successful. +func verifyContent(ctx context.Context, image Image, layout Layout, pusher ContentPusher, sleep SleepFunc) error { + if err := withRetry(ctx, sleep, "verify index", layout.Index.Digest, func() error { + return pusher.Verify(ctx, image.Pin(layout.Index.Digest)) + }); err != nil { + return err + } + for _, platform := range layout.Platforms { + if err := withRetry(ctx, sleep, "verify manifest", platform.Descriptor.Digest, func() error { + return pusher.Verify(ctx, image.Pin(platform.Descriptor.Digest)) + }); err != nil { + return err + } + } + + return nil +} + +// withRetry runs op up to [retryAttempts] times when the error is [ErrRetryable]. +// +// A cancelled context returns immediately. Non-retryable errors fail on the +// first attempt. Exhausted retryable failures name the attempt count. +func withRetry(ctx context.Context, sleep SleepFunc, step string, digest rel.Digest, op func() error) error { + var lastErr error + for attempt := 1; attempt <= retryAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return fmt.Errorf("%s %s: %w", step, digest, err) + } + err := op() + if err == nil { + return nil + } + lastErr = err + if !errors.Is(err, ErrRetryable) || attempt == retryAttempts { + if errors.Is(err, ErrRetryable) { + return fmt.Errorf("%s %s after %d attempts: %w", step, digest, attempt, err) + } + + return fmt.Errorf("%s %s: %w", step, digest, err) + } + if err := sleep(ctx, retryWait<<(attempt-1)); err != nil { + return fmt.Errorf("%s %s: %w", step, digest, err) + } + } + + return fmt.Errorf("%s %s after %d attempts: %w", step, digest, retryAttempts, lastErr) +} + +// openLayoutBlob opens digest's blob file on fsys for streaming. +func openLayoutBlob(fsys fs.FS, digest rel.Digest) (fs.File, error) { + name, err := BlobPath(digest) + if err != nil { + return nil, err + } + file, err := fsys.Open(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return file, nil +} + +// sleepContext waits for d or returns ctx.Err() if the context ends first. +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/stage/puboci/prepare_test.go b/internal/stage/puboci/prepare_test.go new file mode 100644 index 0000000..24af649 --- /dev/null +++ b/internal/stage/puboci/prepare_test.go @@ -0,0 +1,602 @@ +package puboci_test + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "testing/fstest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + cosignmocks "github.com/meigma/release/internal/adapter/cosign/mocks" + regmocks "github.com/meigma/release/internal/adapter/reg/mocks" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +func TestPrepareHappyPath(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + expectEmptyRegistry(tc.reader, tc.plan) + gotContent := expectSuccessfulPublish(t, tc) + + got, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.NoError(t, err) + assert.Equal(t, wantPrepareResult(tc, true), got) + assert.Equal(t, wantPublishOrder(tc), gotContent.order) + assert.Equal(t, wantPushedBytes(t, tc), gotContent.byDigest) +} + +func TestPrepareSharedLayerPushedOnce(t *testing.T) { + t.Parallel() + + shared := []byte(testSharedLayer) + tc := newPrepareTest(t, shared, shared) + expectEmptyRegistry(tc.reader, tc.plan) + gotContent := expectSuccessfulPublish(t, tc) + + got, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.NoError(t, err) + assert.True(t, got.Authoritative) + assert.Equal(t, wantPublishOrder(tc), gotContent.order) + + layerCalls := 0 + layerKey := "blob:" + tc.layout.Blobs[1].Digest.String() + for _, step := range gotContent.order { + if step == layerKey { + layerCalls++ + } + } + assert.Equal(t, 1, layerCalls) + assert.Equal(t, shared, gotContent.byDigest[tc.layout.Blobs[1].Digest.String()]) +} + +func TestPrepareIndexDigestMismatch(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + tc.input.IndexDigest = tc.plan.other + + _, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.Error(t, err) + assert.Contains(t, err.Error(), "layout index digest") + assert.Contains(t, err.Error(), tc.layout.Index.Digest.String()) + assert.Contains(t, err.Error(), tc.plan.other.String()) +} + +func TestPrepareImmutableTagConflict(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + expectDigest(tc.reader, tc.plan.exact, tc.plan.other) + expectAbsent(tc.reader, tc.plan.minor) + expectAbsent(tc.reader, tc.plan.major) + expectAbsent(tc.reader, tc.plan.latest) + + _, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.ErrorIs(t, err, rel.ErrImmutableTag) + assert.Contains(t, err.Error(), "plan tags") +} + +func TestPrepareDryRun(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + tc.input.DryRun = true + expectEmptyRegistry(tc.reader, tc.plan) + + got, err := puboci.Prepare(context.Background(), tc.input, tc.reader, nil, nil) + require.NoError(t, err) + assert.Equal(t, wantPrepareResult(tc, false), got) +} + +func TestPreparePushBlobFailure(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + expectEmptyRegistry(tc.reader, tc.plan) + first := tc.layout.Blobs[0] + tc.pusher.EXPECT(). + PushBlob(mock.Anything, tc.input.Image, first, mock.Anything). + Return(errors.New("blob rejected")). + Once() + + _, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.Error(t, err) + assert.Contains(t, err.Error(), "push blob") + assert.Contains(t, err.Error(), first.Digest.String()) +} + +func TestPrepareIndexVerifyFailure(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + expectEmptyRegistry(tc.reader, tc.plan) + expectPushes(t, tc, &pushedContent{}) + tc.pusher.EXPECT(). + Verify(mock.Anything, tc.input.Image.Pin(tc.layout.Index.Digest)). + Return(errors.New("index missing")). + Once() + + _, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.Error(t, err) + assert.Contains(t, err.Error(), "verify index") + assert.Contains(t, err.Error(), tc.layout.Index.Digest.String()) +} + +func TestPrepareSignFailure(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + expectEmptyRegistry(tc.reader, tc.plan) + expectPushes(t, tc, &pushedContent{}) + expectVerifies(tc, &pushedContent{}) + tc.signer.EXPECT(). + SignRecursive(mock.Anything, tc.input.Image.Pin(tc.layout.Index.Digest)). + Return(errors.New("cosign failed")). + Once() + + got, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.Error(t, err) + assert.Contains(t, err.Error(), "sign") + assert.Contains(t, err.Error(), tc.layout.Index.Digest.String()) + assert.False(t, got.Authoritative) + assert.Equal(t, puboci.OCIPrepareResult{}, got) +} + +func TestPrepareRetryableBlobThenSucceeds(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + var waits []time.Duration + tc.input.Sleep = recordSleep(&waits) + expectEmptyRegistry(tc.reader, tc.plan) + + first := tc.layout.Blobs[0] + var attempts [][]byte + tc.pusher.EXPECT(). + PushBlob(mock.Anything, tc.input.Image, first, mock.Anything). + RunAndReturn(func(_ context.Context, _ puboci.Image, _ puboci.Descriptor, content io.Reader) error { + attempts = append(attempts, readAll(t, content)) + + return puboci.ErrRetryable + }). + Times(2) + tc.pusher.EXPECT(). + PushBlob(mock.Anything, tc.input.Image, first, mock.Anything). + RunAndReturn(func(_ context.Context, _ puboci.Image, _ puboci.Descriptor, content io.Reader) error { + attempts = append(attempts, readAll(t, content)) + + return nil + }). + Once() + + gotContent := &pushedContent{byDigest: make(map[string][]byte)} + for _, blob := range tc.layout.Blobs[1:] { + expectPushBlob(t, tc, blob, gotContent) + } + for _, platform := range tc.layout.Platforms { + expectPushManifest(t, tc, platform.Descriptor, gotContent) + } + expectPushIndex(t, tc, gotContent) + expectVerifies(tc, gotContent) + expectSign(tc, gotContent) + + got, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.NoError(t, err) + assert.True(t, got.Authoritative) + require.Len(t, attempts, 3) + wantBlob := readLayoutFile(t, tc.files, first.Digest) + for _, body := range attempts { + assert.Equal(t, wantBlob, body) + } + assert.Equal(t, []time.Duration{time.Second, 2 * time.Second}, waits) +} + +func TestPrepareRetryablePushExhausted(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + var waits []time.Duration + tc.input.Sleep = recordSleep(&waits) + expectEmptyRegistry(tc.reader, tc.plan) + first := tc.layout.Blobs[0] + tc.pusher.EXPECT(). + PushBlob(mock.Anything, tc.input.Image, first, mock.Anything). + Return(puboci.ErrRetryable). + Times(4) + + _, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.ErrorIs(t, err, puboci.ErrRetryable) + assert.Contains(t, err.Error(), "push blob") + assert.Contains(t, err.Error(), "after 4 attempts") + assert.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, waits) +} + +func TestPrepareRetryableVerifyThenSucceeds(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + var waits []time.Duration + tc.input.Sleep = recordSleep(&waits) + expectEmptyRegistry(tc.reader, tc.plan) + expectPushes(t, tc, &pushedContent{}) + indexRef := tc.input.Image.Pin(tc.layout.Index.Digest) + tc.pusher.EXPECT(). + Verify(mock.Anything, indexRef). + Return(puboci.ErrRetryable). + Once() + tc.pusher.EXPECT(). + Verify(mock.Anything, indexRef). + Return(nil). + Once() + for _, platform := range tc.layout.Platforms { + tc.pusher.EXPECT(). + Verify(mock.Anything, tc.input.Image.Pin(platform.Descriptor.Digest)). + Return(nil). + Once() + } + tc.signer.EXPECT(). + SignRecursive(mock.Anything, indexRef). + Return(nil). + Once() + + got, err := puboci.Prepare(context.Background(), tc.input, tc.reader, tc.pusher, tc.signer) + require.NoError(t, err) + assert.True(t, got.Authoritative) + assert.Equal(t, []time.Duration{time.Second}, waits) +} + +func TestPrepareCancelDuringBackoff(t *testing.T) { + t.Parallel() + + tc := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + expectEmptyRegistry(tc.reader, tc.plan) + first := tc.layout.Blobs[0] + tc.pusher.EXPECT(). + PushBlob(mock.Anything, tc.input.Image, first, mock.Anything). + Return(puboci.ErrRetryable). + Once() + + ctx, cancel := context.WithCancel(context.Background()) + tc.input.Sleep = func(_ context.Context, _ time.Duration) error { + cancel() + + return context.Canceled + } + + _, err := puboci.Prepare(ctx, tc.input, tc.reader, tc.pusher, tc.signer) + require.ErrorIs(t, err, context.Canceled) +} + +func TestPrepareRejectsInvalidInput(t *testing.T) { + t.Parallel() + + valid := newPrepareTest(t, []byte(testAMD64Layer), []byte(testARM64Layer)) + + tests := []struct { + name string + ctx context.Context + input puboci.PrepareInput + state puboci.StateReader + wantErr string + }{ + { + name: "nil context", + input: valid.input, + state: valid.reader, + wantErr: "context is nil", + }, + { + name: "nil layout", + ctx: context.Background(), + input: puboci.PrepareInput{ + Image: valid.input.Image, + Version: valid.input.Version, + IndexDigest: valid.input.IndexDigest, + }, + state: valid.reader, + wantErr: "layout is nil", + }, + { + name: "nil state reader", + ctx: context.Background(), + input: puboci.PrepareInput{ + Image: valid.input.Image, + Version: valid.input.Version, + IndexDigest: valid.input.IndexDigest, + Layout: valid.input.Layout, + }, + wantErr: "state reader is nil", + }, + { + name: "zero digest", + ctx: context.Background(), + input: puboci.PrepareInput{ + Image: valid.input.Image, + Version: valid.input.Version, + Layout: valid.input.Layout, + }, + state: valid.reader, + wantErr: "digest is empty", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := puboci.Prepare(test.ctx, test.input, test.state, valid.pusher, valid.signer) + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +// prepareTest is one Prepare invocation and the collaborators it needs. +type prepareTest struct { + // input is the candidate prepare request. + input puboci.PrepareInput + // layout is the validated form of input.Layout. + layout puboci.Layout + // files is the extracted layout directory used to build input.Layout. + files fstest.MapFS + // plan is the repository and tags CollectState reads. + plan planFixture + // reader is the registry state port. + reader *regmocks.MockStateReader + // pusher is the registry content port. + pusher *regmocks.MockContentPusher + // signer is the recursive signing port. + signer *cosignmocks.MockSigner +} + +// pushedContent records publish order and the bytes streamed for each digest. +type pushedContent struct { + // order is method:digest entries in the order the write ports ran. + order []string + // byDigest is the exact reader contents keyed by digest. + byDigest map[string][]byte +} + +// newPrepareTest builds a two-platform layout and unused write ports. +func newPrepareTest(t *testing.T, amd64Layer, arm64Layer []byte) *prepareTest { + t.Helper() + + fixture := newTwoPlatformLayout(t, amd64Layer, arm64Layer) + layout, err := puboci.ReadLayout(fixture.files) + require.NoError(t, err) + + plan := newPlanFixture(t) + plan.digest = layout.Index.Digest + + return &prepareTest{ + input: puboci.PrepareInput{ + Image: plan.image, + Version: plan.version, + IndexDigest: layout.Index.Digest, + Layout: fixture.files, + Sleep: instantSleep, + }, + layout: layout, + files: fixture.files, + plan: plan, + reader: regmocks.NewMockStateReader(t), + pusher: regmocks.NewMockContentPusher(t), + signer: cosignmocks.NewMockSigner(t), + } +} + +// expectEmptyRegistry expects every planned tag to be absent. +func expectEmptyRegistry(reader *regmocks.MockStateReader, plan planFixture) { + expectAbsent(reader, plan.exact) + expectAbsent(reader, plan.minor) + expectAbsent(reader, plan.major) + expectAbsent(reader, plan.latest) +} + +// expectSuccessfulPublish expects the full push, verify, and sign sequence. +func expectSuccessfulPublish(t *testing.T, tc *prepareTest) *pushedContent { + t.Helper() + + got := &pushedContent{byDigest: make(map[string][]byte)} + expectPushes(t, tc, got) + expectVerifies(tc, got) + expectSign(tc, got) + + return got +} + +// expectPushes expects blobs, then platform manifests, then the index. +func expectPushes(t *testing.T, tc *prepareTest, got *pushedContent) { + t.Helper() + + if got.byDigest == nil { + got.byDigest = make(map[string][]byte) + } + for _, blob := range tc.layout.Blobs { + expectPushBlob(t, tc, blob, got) + } + for _, platform := range tc.layout.Platforms { + expectPushManifest(t, tc, platform.Descriptor, got) + } + expectPushIndex(t, tc, got) +} + +// expectPushBlob expects one streamed blob and records its bytes. +func expectPushBlob(t *testing.T, tc *prepareTest, desc puboci.Descriptor, got *pushedContent) { + t.Helper() + + tc.pusher.EXPECT(). + PushBlob(mock.Anything, tc.input.Image, desc, mock.Anything). + RunAndReturn(func(_ context.Context, _ puboci.Image, gotDesc puboci.Descriptor, content io.Reader) error { + recordPush(t, got, "blob", gotDesc, content) + + return nil + }). + Once() +} + +// expectPushManifest expects one streamed platform manifest and records its bytes. +func expectPushManifest(t *testing.T, tc *prepareTest, desc puboci.Descriptor, got *pushedContent) { + t.Helper() + + tc.pusher.EXPECT(). + PushManifest(mock.Anything, tc.input.Image, desc, mock.Anything). + RunAndReturn(func(_ context.Context, _ puboci.Image, gotDesc puboci.Descriptor, content io.Reader) error { + recordPush(t, got, "manifest", gotDesc, content) + + return nil + }). + Once() +} + +// expectPushIndex expects the retained index bytes to be pushed last. +func expectPushIndex(t *testing.T, tc *prepareTest, got *pushedContent) { + t.Helper() + + tc.pusher.EXPECT(). + PushManifest(mock.Anything, tc.input.Image, tc.layout.Index, mock.Anything). + RunAndReturn(func(_ context.Context, _ puboci.Image, gotDesc puboci.Descriptor, content io.Reader) error { + recordPush(t, got, "index", gotDesc, content) + + return nil + }). + Once() +} + +// expectVerifies expects the index, then each platform manifest, to resolve. +func expectVerifies(tc *prepareTest, got *pushedContent) { + tc.pusher.EXPECT(). + Verify(mock.Anything, tc.input.Image.Pin(tc.layout.Index.Digest)). + Run(func(_ context.Context, ref puboci.DigestRef) { + got.order = append(got.order, "verify:"+ref.Digest.String()) + }). + Return(nil). + Once() + for _, platform := range tc.layout.Platforms { + digest := platform.Descriptor.Digest + tc.pusher.EXPECT(). + Verify(mock.Anything, tc.input.Image.Pin(digest)). + Run(func(_ context.Context, ref puboci.DigestRef) { + got.order = append(got.order, "verify:"+ref.Digest.String()) + }). + Return(nil). + Once() + } +} + +// expectSign expects SignRecursive on the published index digest. +func expectSign(tc *prepareTest, got *pushedContent) { + tc.signer.EXPECT(). + SignRecursive(mock.Anything, tc.input.Image.Pin(tc.layout.Index.Digest)). + Run(func(_ context.Context, ref puboci.DigestRef) { + got.order = append(got.order, "sign:"+ref.Digest.String()) + }). + Return(nil). + Once() +} + +// recordPush appends a labelled digest to the observed order and stores the bytes. +func recordPush(t *testing.T, got *pushedContent, kind string, desc puboci.Descriptor, content io.Reader) { + t.Helper() + + body, err := io.ReadAll(content) + require.NoError(t, err) + got.order = append(got.order, kind+":"+desc.Digest.String()) + got.byDigest[desc.Digest.String()] = body +} + +// wantPublishOrder is blobs, platform manifests, index, verifies, then sign. +func wantPublishOrder(tc *prepareTest) []string { + order := make([]string, 0, len(tc.layout.Blobs)+2*len(tc.layout.Platforms)+3) + for _, blob := range tc.layout.Blobs { + order = append(order, "blob:"+blob.Digest.String()) + } + for _, platform := range tc.layout.Platforms { + order = append(order, "manifest:"+platform.Descriptor.Digest.String()) + } + order = append(order, "index:"+tc.layout.Index.Digest.String()) + order = append(order, "verify:"+tc.layout.Index.Digest.String()) + for _, platform := range tc.layout.Platforms { + order = append(order, "verify:"+platform.Descriptor.Digest.String()) + } + order = append(order, "sign:"+tc.layout.Index.Digest.String()) + + return order +} + +// wantPushedBytes is the layout file contents keyed by digest. +func wantPushedBytes(t *testing.T, tc *prepareTest) map[string][]byte { + t.Helper() + + want := make(map[string][]byte, len(tc.layout.Blobs)+len(tc.layout.Platforms)+1) + for _, blob := range tc.layout.Blobs { + want[blob.Digest.String()] = readLayoutFile(t, tc.files, blob.Digest) + } + for _, platform := range tc.layout.Platforms { + want[platform.Descriptor.Digest.String()] = readLayoutFile(t, tc.files, platform.Descriptor.Digest) + } + want[tc.layout.Index.Digest.String()] = bytes.Clone(tc.layout.IndexBytes) + + return want +} + +// readLayoutFile reads digest's blob bytes from the fixture filesystem. +func readLayoutFile(t *testing.T, files fstest.MapFS, digest rel.Digest) []byte { + t.Helper() + + name, err := puboci.BlobPath(digest) + require.NoError(t, err) + file, err := files.Open(name) + require.NoError(t, err) + defer func() { + require.NoError(t, file.Close()) + }() + body, err := io.ReadAll(file) + require.NoError(t, err) + + return body +} + +// wantPrepareResult is the document Prepare should emit for tc. +func wantPrepareResult(tc *prepareTest, authoritative bool) puboci.OCIPrepareResult { + return puboci.NewPrepareResult( + tc.input.Image, + tc.input.Version, + tc.layout.Index.Digest, + tc.layout.Platforms, + emptyState(tc.input.Version), + authoritative, + ) +} + +// instantSleep is a SleepFunc that never waits. +func instantSleep(_ context.Context, _ time.Duration) error { + return nil +} + +// recordSleep appends each backoff duration to waits. +func recordSleep(waits *[]time.Duration) puboci.SleepFunc { + return func(_ context.Context, d time.Duration) error { + *waits = append(*waits, d) + + return nil + } +} + +// readAll consumes content and returns the bytes. +func readAll(t *testing.T, content io.Reader) []byte { + t.Helper() + + body, err := io.ReadAll(content) + require.NoError(t, err) + + return body +} diff --git a/internal/stage/puboci/ref.go b/internal/stage/puboci/ref.go new file mode 100644 index 0000000..74d4b31 --- /dev/null +++ b/internal/stage/puboci/ref.go @@ -0,0 +1,51 @@ +package puboci + +import ( + "errors" + "fmt" + + "github.com/meigma/release/internal/rel" +) + +// Descriptor is an OCI content descriptor. +type Descriptor struct { + // MediaType is the OCI media type of the referenced content. + MediaType string + // Digest is the content digest. + Digest rel.Digest + // Size is the content length in bytes. + Size int64 +} + +// DigestRef is an image pinned to a content digest. +type DigestRef struct { + // Image is the untagged repository name. + Image Image + // Digest is the pinned content digest. + Digest rel.Digest +} + +// Validate reports whether d has a media type, a parsable digest, and a non-negative size. +func (d Descriptor) Validate() error { + if d.MediaType == "" { + return errors.New("descriptor media type is empty") + } + if _, err := rel.ParseDigest(d.Digest.String()); err != nil { + return fmt.Errorf("descriptor digest: %w", err) + } + if d.Size < 0 { + return errors.New("descriptor size is negative") + } + + return nil +} + +// Pin binds i to digest. +func (i Image) Pin(digest rel.Digest) DigestRef { + return DigestRef{Image: i, Digest: digest} +} + +// String returns image@digest. +func (r DigestRef) String() string { + return r.Image.String() + "@" + r.Digest.String() +} diff --git a/internal/stage/puboci/ref_test.go b/internal/stage/puboci/ref_test.go new file mode 100644 index 0000000..3672640 --- /dev/null +++ b/internal/stage/puboci/ref_test.go @@ -0,0 +1,102 @@ +package puboci_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +func TestDigestRefString(t *testing.T) { + t.Parallel() + + image := mustImage(t) + digest := mustDigest(t, validDigest) + ref := image.Pin(digest) + + assert.Equal(t, image, ref.Image) + assert.Equal(t, digest, ref.Digest) + assert.Equal(t, "ghcr.io/owner/repo@"+validDigest, ref.String()) +} + +func TestImagePin(t *testing.T) { + t.Parallel() + + image := mustImage(t) + digest := mustDigest(t, validDigest) + + assert.Equal(t, puboci.DigestRef{Image: image, Digest: digest}, image.Pin(digest)) +} + +func TestDescriptorValidate(t *testing.T) { + t.Parallel() + + valid := puboci.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: mustDigest(t, validDigest), + Size: 0, + } + + tests := []struct { + name string + desc puboci.Descriptor + wantErr string + }{ + { + name: "valid zero size", + desc: valid, + }, + { + name: "empty media type", + desc: puboci.Descriptor{ + Digest: valid.Digest, + Size: 1, + }, + wantErr: "descriptor media type is empty", + }, + { + name: "empty digest", + desc: puboci.Descriptor{ + MediaType: valid.MediaType, + Size: 1, + }, + wantErr: "descriptor digest:", + }, + { + name: "malformed digest", + desc: puboci.Descriptor{ + MediaType: valid.MediaType, + Digest: rel.Digest("sha256:abcd"), + Size: 1, + }, + wantErr: "descriptor digest:", + }, + { + name: "negative size", + desc: puboci.Descriptor{ + MediaType: valid.MediaType, + Digest: valid.Digest, + Size: -1, + }, + wantErr: "descriptor size is negative", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := test.desc.Validate() + if test.wantErr == "" { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} diff --git a/internal/stage/puboci/result.go b/internal/stage/puboci/result.go new file mode 100644 index 0000000..6ea2669 --- /dev/null +++ b/internal/stage/puboci/result.go @@ -0,0 +1,174 @@ +package puboci + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/meigma/release/internal/rel" +) + +const ( + // PrepareSchema is the versioned OCI prepare-result identifier. + PrepareSchema = "release.dev/oci-prepare/v1" + // exactObservationCount is the exact-version tag that precedes channel observations. + exactObservationCount = 1 +) + +// AttestationSubject is one platform manifest that later attestation names. +type AttestationSubject struct { + // Platform is the os/architecture pair, such as linux/amd64. + Platform string `json:"platform"` + // Digest is the platform manifest digest. + Digest string `json:"digest"` +} + +// TagObservation is the registry state of one exact or channel tag. +type TagObservation struct { + // Tag is the registry tag. + Tag string `json:"tag"` + // Scope is exact, minor, major, or latest. + Scope string `json:"scope"` + // Present reports whether the tag currently resolves. + Present bool `json:"present"` + // Digest is the resolved digest. It is omitted when the tag is absent. + Digest string `json:"digest,omitempty"` + // Version is the annotated version. It is omitted when none was read. + Version string `json:"version,omitempty"` +} + +// OCIPrepareResult is the versioned document produced by publish oci prepare. +type OCIPrepareResult struct { + // Schema identifies the prepare-result version and is always [PrepareSchema]. + Schema string `json:"schema"` + // Authoritative is false for --dry-run and true after a real prepare. + Authoritative bool `json:"authoritative"` + // Image is the untagged repository name. + Image string `json:"image"` + // Version is the candidate MAJOR.MINOR.PATCH version. + Version string `json:"version"` + // IndexDigest is the image index digest. + IndexDigest string `json:"index_digest"` + // Platforms are the layout's platform manifests in layout order. + Platforms []AttestationSubject `json:"platforms"` + // Observed is the exact tag, then minor, major, and latest. + Observed []TagObservation `json:"observed"` +} + +// NewPrepareResult renders a prepare document from domain values. +// +// Platforms stay in the order supplied by the layout. Observed is the exact +// tag followed by each channel from [rel.ChannelsFor]. A channel missing +// from state.Channels is recorded as absent. +func NewPrepareResult( + image Image, + version rel.Version, + index rel.Digest, + platforms []PlatformImage, + state rel.ChannelState, + authoritative bool, +) OCIPrepareResult { + subjects := make([]AttestationSubject, 0, len(platforms)) + for _, platform := range platforms { + subjects = append(subjects, AttestationSubject{ + Platform: platform.Platform.String(), + Digest: platform.Descriptor.Digest.String(), + }) + } + + channels := rel.ChannelsFor(version) + observed := make([]TagObservation, 0, exactObservationCount+len(channels)) + observed = append(observed, observeTag(version.Tag().String(), rel.ScopeExact, state.Exact)) + for _, channel := range channels { + observed = append(observed, observeTag(channel.Tag.String(), channel.Scope, state.Channels[channel])) + } + + return OCIPrepareResult{ + Schema: PrepareSchema, + Authoritative: authoritative, + Image: image.String(), + Version: version.String(), + IndexDigest: index.String(), + Platforms: subjects, + Observed: observed, + } +} + +// ParsePrepareResult decodes one prepare document from r and validates it. +// +// Decoding rejects unknown fields. Documents are limited to [jsonLimitBytes]. +func ParsePrepareResult(r io.Reader) (OCIPrepareResult, error) { + if r == nil { + return OCIPrepareResult{}, errors.New("reader is nil") + } + + decoder := json.NewDecoder(io.LimitReader(r, jsonLimitBytes)) + decoder.DisallowUnknownFields() + + var result OCIPrepareResult + if err := decoder.Decode(&result); err != nil { + return OCIPrepareResult{}, fmt.Errorf("prepare result: %w", err) + } + if err := result.Validate(); err != nil { + return OCIPrepareResult{}, err + } + + return result, nil +} + +// Validate reports whether r is a well-formed prepare document. +// +// It rejects a schema other than [PrepareSchema], an empty image, an +// unparsable version or index digest, an empty platform list, a platform +// subject with an empty platform or an unparsable digest, and an absent +// tag observation that still carries a digest. +func (r OCIPrepareResult) Validate() error { + if r.Schema != PrepareSchema { + return fmt.Errorf("prepare result schema %q is unsupported", r.Schema) + } + if r.Image == "" { + return errors.New("prepare result image is empty") + } + if _, err := rel.ParseVersion(r.Version); err != nil { + return fmt.Errorf("prepare result version: %w", err) + } + if _, err := rel.ParseDigest(r.IndexDigest); err != nil { + return fmt.Errorf("prepare result index digest: %w", err) + } + if len(r.Platforms) == 0 { + return errors.New("prepare result has no platforms") + } + for i, platform := range r.Platforms { + if platform.Platform == "" { + return fmt.Errorf("prepare result platforms[%d] platform is empty", i) + } + if _, err := rel.ParseDigest(platform.Digest); err != nil { + return fmt.Errorf("prepare result platforms[%d] digest: %w", i, err) + } + } + for i, observation := range r.Observed { + if !observation.Present && observation.Digest != "" { + return fmt.Errorf("prepare result observed[%d] is absent but has digest %q", i, observation.Digest) + } + } + + return nil +} + +// observeTag renders one tag's observed registry state. +func observeTag(tag string, scope rel.Scope, state rel.TagState) TagObservation { + observation := TagObservation{ + Tag: tag, + Scope: string(scope), + Present: state.Present, + } + if state.Present { + observation.Digest = state.Digest.String() + } + if state.HasVersion { + observation.Version = state.Version.String() + } + + return observation +} diff --git a/internal/stage/puboci/result_test.go b/internal/stage/puboci/result_test.go new file mode 100644 index 0000000..2d0d07f --- /dev/null +++ b/internal/stage/puboci/result_test.go @@ -0,0 +1,312 @@ +package puboci_test + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +func TestNewPrepareResult(t *testing.T) { + t.Parallel() + + fixture := newPrepareFixture(t) + got := puboci.NewPrepareResult( + fixture.image, + fixture.version, + fixture.index, + fixture.platforms, + fixture.state, + false, + ) + + assert.Equal(t, puboci.OCIPrepareResult{ + Schema: puboci.PrepareSchema, + Authoritative: false, + Image: "ghcr.io/owner/repo", + Version: "1.2.3", + IndexDigest: validDigest, + Platforms: []puboci.AttestationSubject{ + {Platform: "linux/amd64", Digest: validDigest}, + {Platform: "linux/arm64", Digest: otherDigest}, + }, + Observed: []puboci.TagObservation{ + {Tag: "1.2.3", Scope: string(rel.ScopeExact), Present: false}, + { + Tag: "1.2", + Scope: string(rel.ScopeMinor), + Present: true, + Digest: otherDigest, + Version: "1.2.2", + }, + {Tag: "1", Scope: string(rel.ScopeMajor), Present: false}, + {Tag: "latest", Scope: string(rel.ScopeLatest), Present: false}, + }, + }, got) +} + +func TestParsePrepareResultRoundTrip(t *testing.T) { + t.Parallel() + + fixture := newPrepareFixture(t) + original := puboci.NewPrepareResult( + fixture.image, + fixture.version, + fixture.index, + fixture.platforms, + fixture.state, + false, + ) + payload, err := json.Marshal(original) + require.NoError(t, err) + + got, err := puboci.ParsePrepareResult(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, original, got) + assert.False(t, got.Authoritative) +} + +func TestParsePrepareResultRejectsUnknownField(t *testing.T) { + t.Parallel() + + payload := `{ + "schema":"` + puboci.PrepareSchema + `", + "authoritative":true, + "image":"ghcr.io/owner/repo", + "version":"1.2.3", + "index_digest":"` + validDigest + `", + "platforms":[{"platform":"linux/amd64","digest":"` + validDigest + `"}], + "observed":[], + "extra":true + }` + + _, err := puboci.ParsePrepareResult(strings.NewReader(payload)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown field") +} + +func TestParsePrepareResultRejectsOverLimit(t *testing.T) { + t.Parallel() + + _, err := puboci.ParsePrepareResult(overLimitPrepareReader()) + require.Error(t, err) + assert.Contains(t, err.Error(), "prepare result:") + assert.NotContains(t, err.Error(), "unsupported") +} + +func TestOCIPrepareResultValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult + wantErr string + }{ + { + name: "valid", + }, + { + name: "wrong schema", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Schema = "release.dev/oci-prepare/v0" + return result + }, + wantErr: `prepare result schema "release.dev/oci-prepare/v0" is unsupported`, + }, + { + name: "empty image", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Image = "" + return result + }, + wantErr: "prepare result image is empty", + }, + { + name: "unparsable version", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Version = "v1.2.3" + return result + }, + wantErr: "prepare result version:", + }, + { + name: "unparsable index digest", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.IndexDigest = "sha256:abcd" + return result + }, + wantErr: "prepare result index digest:", + }, + { + name: "empty platforms", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Platforms = nil + return result + }, + wantErr: "prepare result has no platforms", + }, + { + name: "empty platform", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Platforms[0].Platform = "" + return result + }, + wantErr: "prepare result platforms[0] platform is empty", + }, + { + name: "bad platform digest", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Platforms[0].Digest = "sha256:abcd" + return result + }, + wantErr: "prepare result platforms[0] digest:", + }, + { + name: "absent observation with digest", + mutate: func(result puboci.OCIPrepareResult) puboci.OCIPrepareResult { + result.Observed[0].Present = false + result.Observed[0].Digest = validDigest + return result + }, + wantErr: `prepare result observed[0] is absent but has digest "` + validDigest + `"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + result := validPrepareResult(t) + if test.mutate != nil { + result = test.mutate(result) + } + + err := result.Validate() + if test.wantErr == "" { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestParsePrepareResultValidates(t *testing.T) { + t.Parallel() + + payload, err := json.Marshal(puboci.OCIPrepareResult{ + Schema: "release.dev/oci-prepare/v0", + Image: "ghcr.io/owner/repo", + Version: "1.2.3", + IndexDigest: validDigest, + Platforms: []puboci.AttestationSubject{ + {Platform: "linux/amd64", Digest: validDigest}, + }, + }) + require.NoError(t, err) + + _, err = puboci.ParsePrepareResult(bytes.NewReader(payload)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +// prepareFixture holds domain values for [puboci.NewPrepareResult]. +type prepareFixture struct { + // image is the repository being prepared. + image puboci.Image + // version is the candidate release version. + version rel.Version + // index is the image index digest. + index rel.Digest + // platforms are layout platforms in file order. + platforms []puboci.PlatformImage + // state has an absent exact tag, a versioned minor, and a missing major. + state rel.ChannelState +} + +// newPrepareFixture constructs a 1.2.3 prepare document input. +func newPrepareFixture(t *testing.T) prepareFixture { + t.Helper() + + version := rel.Version{Major: 1, Minor: 2, Patch: 3} + channels := rel.ChannelsFor(version) + minor := channels[0] + latest := channels[2] + + return prepareFixture{ + image: mustImage(t), + version: version, + index: mustDigest(t, validDigest), + platforms: []puboci.PlatformImage{ + { + Descriptor: puboci.Descriptor{Digest: mustDigest(t, validDigest)}, + Platform: puboci.Platform{OS: "linux", Architecture: "amd64"}, + }, + { + Descriptor: puboci.Descriptor{Digest: mustDigest(t, otherDigest)}, + Platform: puboci.Platform{OS: "linux", Architecture: "arm64"}, + }, + }, + state: rel.ChannelState{ + Channels: map[rel.Channel]rel.TagState{ + minor: { + Present: true, + Digest: mustDigest(t, otherDigest), + HasVersion: true, + Version: rel.Version{Major: 1, Minor: 2, Patch: 2}, + }, + latest: {}, + }, + }, + } +} + +// validPrepareResult returns a document that passes [puboci.OCIPrepareResult.Validate]. +func validPrepareResult(t *testing.T) puboci.OCIPrepareResult { + t.Helper() + + fixture := newPrepareFixture(t) + + return puboci.NewPrepareResult( + fixture.image, + fixture.version, + fixture.index, + fixture.platforms, + fixture.state, + true, + ) +} + +// overLimitPrepareReader streams a prepare document larger than the JSON bound. +func overLimitPrepareReader() io.Reader { + prefix := `{"schema":"` + puboci.PrepareSchema + `","authoritative":false,"image":"` + suffix := `","version":"1.2.3","index_digest":"` + validDigest + + `","platforms":[{"platform":"linux/amd64","digest":"` + validDigest + `"}],"observed":[]}` + + return io.MultiReader( + strings.NewReader(prefix), + io.LimitReader(repeatByteReader('a'), testOverJSONLimitBytes), + strings.NewReader(suffix), + ) +} + +// repeatByteReader yields an endless stream of one byte. +type repeatByteReader byte + +// Read fills p with the repeated byte. +func (r repeatByteReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte(r) + } + + return len(p), nil +} diff --git a/internal/stage/puboci/tags_test.go b/internal/stage/puboci/tags_test.go index 6809423..7b0a7c9 100644 --- a/internal/stage/puboci/tags_test.go +++ b/internal/stage/puboci/tags_test.go @@ -68,7 +68,7 @@ func TestParseImage(t *testing.T) { func TestReferenceString(t *testing.T) { t.Parallel() - image := mustImage(t, "ghcr.io/owner/repo") + image := mustImage(t) ref := image.Reference(rel.Tag("1.2.3")) assert.Equal(t, "ghcr.io/owner/repo:1.2.3", ref.String()) } @@ -335,7 +335,7 @@ type planFixture struct { func newPlanFixture(t *testing.T) planFixture { t.Helper() - image := mustImage(t, "ghcr.io/owner/repo") + image := mustImage(t) version := rel.Version{Major: 1, Minor: 2, Patch: 3} channels := rel.ChannelsFor(version) @@ -352,11 +352,14 @@ func newPlanFixture(t *testing.T) planFixture { } } -// mustImage parses an image name or fails the test. -func mustImage(t *testing.T, value string) puboci.Image { +// testImageName is the fixture image every puboci test plans against. +const testImageName = "ghcr.io/owner/repo" + +// mustImage parses the fixture image name or fails the test. +func mustImage(t *testing.T) puboci.Image { t.Helper() - image, err := puboci.ParseImage(value) + image, err := puboci.ParseImage(testImageName) require.NoError(t, err) return image