Skip to content

Commit 20a12c9

Browse files
authored
feat: add PolicyCheck callback to CopyOptions (#1189)
## What Adds an optional `PolicyCheck` callback to `CopyOptions`: ```go PolicyCheck func(ctx context.Context, srcRef string) error ``` `Copy` invokes it once with the source reference before any I/O. A non-nil return aborts the copy and the error is wrapped via `%w` (`policy check failed for %s: %w`). When `PolicyCheck` is nil the behavior is unchanged. ## Why Lets callers plug in policy enforcement (e.g., containers-policy.json signature/identity checks) without the root `oras` package taking a build-time dependency on the `policy` package. Keeps the `oras` <-> `registry/remote/policy` boundary clean. Part of the v3 PR-by-PR breakdown (**PR 17: `feat/copy-policy-check`**). Self-contained; no new package deps. (Note: prs.md lists `content_test.go`, `example_test.go`, and `example_copy_test.go` under this PR, but those diffs in `feat/everything` are actually `repo.Registry.PlainHTTP` / `NewCredentialFunc` renames that belong to PR 13. They are intentionally excluded here so this PR stays scoped to PolicyCheck.) ## Test plan - [x] `go build -mod=mod ./...` - [x] `go test -mod=mod -run TestCopy_PolicyCheck -v .` — PASS for `rejected`, `allowed`, `no policy` subtests - [x] `go test -mod=mod -run Copy .` — full Copy test family passes Signed-off-by: Terry Howe <terrylhowe@gmail.com>
1 parent 702345e commit 20a12c9

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

copy.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ type CopyOptions struct {
5454
// reference will be passed to MapRoot, and the mapped descriptor will be
5555
// used as the root node for copy.
5656
MapRoot func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error)
57+
// PolicyCheck is an optional callback invoked before copying begins.
58+
// If set, it is called with the source reference string. If it returns
59+
// a non-nil error, the copy is aborted. This allows decoupled policy
60+
// enforcement (e.g., containers-policy.json) without importing the
61+
// policy package into the root oras package.
62+
PolicyCheck func(ctx context.Context, srcRef string) error
5763
}
5864

5965
// WithTargetPlatform configures opts.MapRoot to select the manifest whose
@@ -136,6 +142,14 @@ func Copy(ctx context.Context, src ReadOnlyTarget, srcRef string, dst Target, ds
136142
if dst == nil {
137143
return ocispec.Descriptor{}, newCopyError("Copy", CopyErrorOriginDestination, errors.New("nil destination target"))
138144
}
145+
146+
// Run policy check before copy if configured.
147+
if opts.PolicyCheck != nil {
148+
if err := opts.PolicyCheck(ctx, srcRef); err != nil {
149+
return ocispec.Descriptor{}, fmt.Errorf("policy check failed for %s: %w", srcRef, err)
150+
}
151+
}
152+
139153
if dstRef == "" {
140154
dstRef = srcRef
141155
}

copy_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2635,3 +2635,98 @@ type badMounter struct {
26352635
func (bm *badMounter) Mount(_ context.Context, _ ocispec.Descriptor, _ string, _ func() (io.ReadCloser, error)) error {
26362636
return errMount
26372637
}
2638+
2639+
func TestCopy_PolicyCheck(t *testing.T) {
2640+
src := memory.New()
2641+
ctx := context.Background()
2642+
2643+
// Push config, layer, and manifest to source
2644+
configContent := []byte("{}")
2645+
configDesc := ocispec.Descriptor{
2646+
MediaType: ocispec.MediaTypeImageConfig,
2647+
Digest: digest.FromBytes(configContent),
2648+
Size: int64(len(configContent)),
2649+
}
2650+
if err := src.Push(ctx, configDesc, bytes.NewReader(configContent)); err != nil {
2651+
t.Fatal(err)
2652+
}
2653+
2654+
layerContent := []byte("test layer")
2655+
layerDesc := ocispec.Descriptor{
2656+
MediaType: ocispec.MediaTypeImageLayer,
2657+
Digest: digest.FromBytes(layerContent),
2658+
Size: int64(len(layerContent)),
2659+
}
2660+
if err := src.Push(ctx, layerDesc, bytes.NewReader(layerContent)); err != nil {
2661+
t.Fatal(err)
2662+
}
2663+
2664+
manifest := ocispec.Manifest{
2665+
MediaType: ocispec.MediaTypeImageManifest,
2666+
Config: configDesc,
2667+
Layers: []ocispec.Descriptor{layerDesc},
2668+
}
2669+
manifestJSON, _ := json.Marshal(manifest)
2670+
manifestDesc := ocispec.Descriptor{
2671+
MediaType: ocispec.MediaTypeImageManifest,
2672+
Digest: digest.FromBytes(manifestJSON),
2673+
Size: int64(len(manifestJSON)),
2674+
}
2675+
if err := src.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil {
2676+
t.Fatal(err)
2677+
}
2678+
if err := src.Tag(ctx, manifestDesc, "latest"); err != nil {
2679+
t.Fatal(err)
2680+
}
2681+
2682+
// Test: PolicyCheck rejects the copy
2683+
t.Run("rejected", func(t *testing.T) {
2684+
dst := memory.New()
2685+
policyErr := errors.New("image not allowed by policy")
2686+
opts := oras.CopyOptions{
2687+
PolicyCheck: func(ctx context.Context, srcRef string) error {
2688+
return policyErr
2689+
},
2690+
}
2691+
2692+
_, err := oras.Copy(ctx, src, "latest", dst, "latest", opts)
2693+
if err == nil {
2694+
t.Fatal("Copy() should fail when PolicyCheck rejects")
2695+
}
2696+
if !errors.Is(err, policyErr) {
2697+
t.Errorf("error should wrap policy error, got: %v", err)
2698+
}
2699+
})
2700+
2701+
// Test: PolicyCheck allows the copy
2702+
t.Run("allowed", func(t *testing.T) {
2703+
dst := memory.New()
2704+
var policyChecked bool
2705+
opts := oras.CopyOptions{
2706+
PolicyCheck: func(ctx context.Context, srcRef string) error {
2707+
policyChecked = true
2708+
if srcRef != "latest" {
2709+
t.Errorf("PolicyCheck got srcRef=%q, want %q", srcRef, "latest")
2710+
}
2711+
return nil
2712+
},
2713+
}
2714+
2715+
_, err := oras.Copy(ctx, src, "latest", dst, "latest", opts)
2716+
if err != nil {
2717+
t.Fatalf("Copy() error = %v", err)
2718+
}
2719+
if !policyChecked {
2720+
t.Error("PolicyCheck was not called")
2721+
}
2722+
})
2723+
2724+
// Test: no PolicyCheck (default)
2725+
t.Run("no policy", func(t *testing.T) {
2726+
dst := memory.New()
2727+
_, err := oras.Copy(ctx, src, "latest", dst, "latest", oras.CopyOptions{})
2728+
if err != nil {
2729+
t.Fatalf("Copy() error = %v", err)
2730+
}
2731+
})
2732+
}

0 commit comments

Comments
 (0)