diff --git a/build/build.go b/build/build.go index efb54d11e..51b035f6d 100644 --- a/build/build.go +++ b/build/build.go @@ -805,11 +805,10 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ itpull := imagetools.New(imageopt) - ref, err := reference.ParseNormalizedNamed(names[0]) + ref, err := imagetools.ParseLocation(names[0]) if err != nil { return err } - ref = reference.TagNameOnly(ref) srcs := make([]*imagetools.Source, len(descs)) for i, desc := range descs { @@ -832,7 +831,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ itpush := imagetools.New(imageopt) for _, n := range names { - nn, err := reference.ParseNormalizedNamed(n) + nn, err := imagetools.ParseLocation(n) if err != nil { return err } diff --git a/build/opt.go b/build/opt.go index 51ab9c8b4..f7522fe87 100644 --- a/build/opt.go +++ b/build/opt.go @@ -27,6 +27,7 @@ import ( "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/dockerutil" + "github.com/docker/buildx/util/ocilayout" "github.com/docker/buildx/util/osutil" "github.com/docker/buildx/util/progress" "github.com/docker/buildx/util/sourcemeta" @@ -913,7 +914,11 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro // handle OCI layout if localPath, ok := strings.CutPrefix(v.Path, "oci-layout://"); ok { - localPath, dig, tag := parseOCILayoutPath(localPath) + ref, _, err := ocilayout.Parse("oci-layout://" + localPath) + if err != nil { + return nil, err + } + localPath, dig, tag := ref.Path, ref.Digest.String(), ref.Tag if dig == "" { dig, err = resolveDigest(localPath, tag) if err != nil { @@ -1402,38 +1407,6 @@ func isActive(ce *client.CacheOptionsEntry) bool { return ce.Attrs["token"] != "" && (ce.Attrs["url"] != "" || ce.Attrs["url_v2"] != "") } -// parseOCILayoutPath handles the oci-layout url accepted by buildx. -func parseOCILayoutPath(s string) (localPath, dgst, tag string) { - localPath = s - - // Look for the digest reference. There might be multiple @ symbols - // in the path and the @ symbol may be part of the path or part of - // the digest. If we find the @ symbol, verify that it's a valid - // digest reference instead of just assuming it is because it - // might be part of the file path. - if i := strings.LastIndex(localPath, "@"); i >= 0 { - after := localPath[i+1:] - if reference.DigestRegexp.MatchString(after) { - localPath, dgst = localPath[:i], after - } - } - - // Do the same with the tag. This isn't as necessary since colons - // aren't valid as file paths on Linux/Unix systems, but they are valid - // on Windows systems so we might as well just be safe. - if i := strings.LastIndex(localPath, ":"); i >= 0 { - after := localPath[i+1:] - if reference.TagRegexp.MatchString(after) { - localPath, tag = localPath[:i], after - } - } - - if tag == "" { - tag = "latest" - } - return -} - func defaultPlatform(bopts gateway.BuildOpts) *ocispecs.Platform { pl := bopts.Workers[0].Platforms if len(pl) == 0 { diff --git a/build/opt_test.go b/build/opt_test.go index fd7d449ce..0e4499a98 100644 --- a/build/opt_test.go +++ b/build/opt_test.go @@ -40,48 +40,6 @@ func TestCacheOptions_DerivedVars(t *testing.T) { }, CreateCaches(cacheFrom)) } -func TestParseOCILayoutPath(t *testing.T) { - for _, tt := range []struct { - s string - path string - dgst string - tag string - }{ - { - s: "/path/to/oci/layout", - path: "/path/to/oci/layout", - tag: "latest", - }, - { - s: "/path/to/oci/layout:1.3", - path: "/path/to/oci/layout", - tag: "1.3", - }, - { - s: "/path/to/oci/layout@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - path: "/path/to/oci/layout", - dgst: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - tag: "latest", - }, - { - s: "/path/to/oci/@/layout@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - path: "/path/to/oci/@/layout", - dgst: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - tag: "latest", - }, - { - s: "/path/to/oci/@/layout", - path: "/path/to/oci/@/layout", - tag: "latest", - }, - } { - path, dgst, tag := parseOCILayoutPath(tt.s) - assert.Equal(t, tt.path, path, "comparing path: %s", tt.s) - assert.Equal(t, tt.dgst, dgst, "comparing digest: %s", tt.s) - assert.Equal(t, tt.tag, tag, "comparing tag: %s", tt.s) - } -} - func TestCreateExports_RegistryUnpack(t *testing.T) { tests := []struct { name string diff --git a/commands/imagetools/create.go b/commands/imagetools/create.go index bd949c929..bea7f36be 100644 --- a/commands/imagetools/create.go +++ b/commands/imagetools/create.go @@ -10,7 +10,6 @@ import ( "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/platforms" - "github.com/distribution/reference" "github.com/docker/buildx/builder" "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/cobrautil/completion" @@ -60,7 +59,7 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg args = append(fileArgs, args...) - tags, err := parseRefs(in.tags) + tags, err := parseLocations(in.tags) if err != nil { return err } @@ -102,10 +101,16 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg return errors.Errorf("no repositories specified, please set a reference in tag or source") } - var defaultRepo *string + var defaultRepo *imagetools.Location if len(repos) == 1 { - for repo := range repos { - defaultRepo = &repo + for _, src := range srcs { + if src.Ref != nil { + defaultRepo = src.Ref + break + } + } + if defaultRepo == nil && len(tags) > 0 { + defaultRepo = tags[0] } } @@ -114,19 +119,19 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg if defaultRepo == nil { return errors.Errorf("multiple repositories specified, cannot infer repository for %q", args[i]) } - n, err := reference.ParseNormalizedNamed(*defaultRepo) - if err != nil { - return err - } if s.Desc.MediaType == "" && s.Desc.Digest != "" { - r, err := reference.WithDigest(n, s.Desc.Digest) + r, err := defaultRepo.WithDigest(s.Desc.Digest) if err != nil { return err } srcs[i].Ref = r sourceRefs = true } else { - srcs[i].Ref = reference.TagNameOnly(n) + r, err := defaultRepo.TagNameOnly() + if err != nil { + return err + } + srcs[i].Ref = r } } } @@ -209,7 +214,7 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg eg, _ := errgroup.WithContext(ctx) pw := progress.WithPrefix(printer, "internal", true) - tagsByRepo := map[string][]reference.Named{} + tagsByRepo := map[string][]*imagetools.Location{} for _, t := range tags { repo := t.Name() tagsByRepo[repo] = append(tagsByRepo[repo], t) @@ -224,10 +229,14 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg for _, desc := range manifests { eg2.Go(func() error { sub.Log(1, fmt.Appendf(nil, "copying %s from %s to %s\n", desc.Digest.String(), desc.Source.Ref.String(), repo)) - return r.Copy(ctx, &imagetools.Source{ + err := r.Copy(ctx, &imagetools.Source{ Ref: desc.Source.Ref, Desc: desc.Descriptor, }, seed) + if err != nil { + return errors.Wrapf(err, "copy %s from %s to %s", desc.Digest.String(), desc.Source.Ref.String(), seed.String()) + } + return nil }) } if err := eg2.Wait(); err != nil { @@ -236,7 +245,7 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg for _, t := range repoTags { sub.Log(1, fmt.Appendf(nil, "pushing %s to %s\n", desc.Digest.String(), t.String())) if err := r.Push(ctx, t, desc, dt); err != nil { - return err + return errors.Wrapf(err, "publish %s to %s", desc.Digest.String(), t.String()) } } return nil @@ -302,10 +311,10 @@ func withMediaTypeKeyPrefix(ctx context.Context) context.Context { return ctx } -func parseRefs(in []string) ([]reference.Named, error) { - refs := make([]reference.Named, len(in)) +func parseLocations(in []string) ([]*imagetools.Location, error) { + refs := make([]*imagetools.Location, len(in)) for i, in := range in { - n, err := reference.ParseNormalizedNamed(in) + n, err := imagetools.ParseLocation(in) if err != nil { return nil, err } @@ -327,10 +336,10 @@ func parseSource(in string) (*imagetools.Source, error) { return nil, err } - ref, err := reference.ParseNormalizedNamed(in) + loc, err := imagetools.ParseLocation(in) if err == nil { return &imagetools.Source{ - Ref: ref, + Ref: loc, }, nil } else if !strings.HasPrefix(in, "{") { return nil, err diff --git a/tests/imagetools.go b/tests/imagetools.go index 3bfae4bcc..1cf14bb99 100644 --- a/tests/imagetools.go +++ b/tests/imagetools.go @@ -8,6 +8,7 @@ import ( "os/exec" "path" "path/filepath" + "strings" "testing" "github.com/containerd/containerd/v2/core/content" @@ -31,6 +32,10 @@ var imagetoolsTests = []func(t *testing.T, sb integration.Sandbox){ testImagetoolsCopyIndex, testImagetoolsInspectAndFilter, testImagetoolsCreatePlatformFilter, + testImagetoolsOCILayoutInspect, + testImagetoolsOCILayoutCreateSourceAndTarget, + testImagetoolsOCILayoutMergeSources, + testImagetoolsOCILayoutTargetDigest, testImagetoolsAppend, testImagetoolsFile, testImagetoolsAnnotation, @@ -359,6 +364,179 @@ func testImagetoolsCreatePlatformFilter(t *testing.T, sb integration.Sandbox) { require.Equal(t, 1, attestationCount) } +// testImagetoolsOCILayoutInspect verifies inspect works against local OCI layout sources. +func testImagetoolsOCILayoutInspect(t *testing.T, sb integration.Sandbox) { + if !isDockerContainerWorker(sb) { + t.Skip("only testing with docker-container worker, imagetools only runs on docker-container") + } + + dir := createDockerfileWithArches(t, "amd64", "arm64") + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + source := registry + "/buildx/imtools-oci-layout-inspect-src:latest" + out, err := buildCmd(sb, withArgs("-t", source, "--push", "--platform=linux/amd64,linux/arm64", "--provenance=false", dir)) + require.NoError(t, err, string(out)) + + cmd := buildxCmd(sb, withArgs("imagetools", "inspect", source, "--raw")) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + sourceDigest := digest.FromBytes(dt) + + layoutPath := filepath.Join(dir, "layout-inspect") + layoutRef := "oci-layout://" + layoutPath + ":latest" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", layoutRef, source)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", layoutRef, "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + require.Equal(t, sourceDigest, digest.FromBytes(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", "oci-layout://"+layoutPath+"@"+sourceDigest.String(), "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + require.Equal(t, sourceDigest, digest.FromBytes(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", layoutRef, "--format", "{{.Manifest.Digest}}")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + require.Equal(t, sourceDigest.String(), strings.TrimSpace(string(dt))) +} + +// testImagetoolsOCILayoutCreateSourceAndTarget verifies create can round-trip +// between registry and OCI layout sources and targets. +func testImagetoolsOCILayoutCreateSourceAndTarget(t *testing.T, sb integration.Sandbox) { + if !isDockerContainerWorker(sb) { + t.Skip("only testing with docker-container worker, imagetools only runs on docker-container") + } + + dir := createDockerfileWithArches(t, "amd64", "arm64") + registry1, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + registry2, err := sb.NewRegistry() + require.NoError(t, err) + + source := registry1 + "/buildx/imtools-oci-layout-roundtrip-src:latest" + out, err := buildCmd(sb, withArgs("-t", source, "--push", "--platform=linux/amd64,linux/arm64", "--provenance=false", dir)) + require.NoError(t, err, string(out)) + + cmd := buildxCmd(sb, withArgs("imagetools", "inspect", source, "--raw")) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + sourceDigest := digest.FromBytes(dt) + + layoutPath := filepath.Join(dir, "layout-roundtrip") + layoutRef := "oci-layout://" + layoutPath + ":v1" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", layoutRef, source)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + target := registry2 + "/buildx/imtools-oci-layout-roundtrip-dst:latest" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", target, layoutRef)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + require.Equal(t, sourceDigest, digest.FromBytes(dt)) +} + +// testImagetoolsOCILayoutMergeSources verifies create merges registry and local OCI layout sources. +func testImagetoolsOCILayoutMergeSources(t *testing.T, sb integration.Sandbox) { + if !isDockerContainerWorker(sb) { + t.Skip("only testing with docker-container worker, imagetools only runs on docker-container") + } + + dir := createDockerfileWithArches(t, "amd64", "arm64") + registry1, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + registry2, err := sb.NewRegistry() + require.NoError(t, err) + registry3, err := sb.NewRegistry() + require.NoError(t, err) + + srcLocalSeed := registry1 + "/buildx/imtools-oci-layout-merge-local:latest" + out, err := buildCmd(sb, withArgs("-t", srcLocalSeed, "--push", "--platform=linux/amd64", "--provenance=false", dir)) + require.NoError(t, err, string(out)) + + layoutPath := filepath.Join(dir, "layout-merge") + layoutRef := "oci-layout://" + layoutPath + ":latest" + cmd := buildxCmd(sb, withArgs("imagetools", "create", "-t", layoutRef, srcLocalSeed)) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + srcRegistry := registry2 + "/buildx/imtools-oci-layout-merge-registry:latest" + out, err = buildCmd(sb, withArgs("-t", srcRegistry, "--push", "--platform=linux/arm64", "--provenance=false", dir)) + require.NoError(t, err, string(out)) + + target := registry3 + "/buildx/imtools-oci-layout-merge-target:latest" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", target, layoutRef, srcRegistry)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + var idx ocispecs.Index + err = json.Unmarshal(dt, &idx) + require.NoError(t, err) + require.Len(t, idx.Manifests, 2) +} + +// testImagetoolsOCILayoutTargetDigest verifies exact digest targets work for +// OCI layouts and mismatched digests fail. +func testImagetoolsOCILayoutTargetDigest(t *testing.T, sb integration.Sandbox) { + if !isDockerContainerWorker(sb) { + t.Skip("only testing with docker-container worker, imagetools only runs on docker-container") + } + + dir := createDockerfile(t) + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + source := registry + "/buildx/imtools-oci-layout-digest-src:latest" + out, err := buildCmd(sb, withArgs("-t", source, "--push", "--platform=linux/amd64", "--provenance=false", dir)) + require.NoError(t, err, string(out)) + + cmd := buildxCmd(sb, withArgs("imagetools", "inspect", source, "--raw")) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + sourceDigest := digest.FromBytes(dt) + + layoutPath := filepath.Join(dir, "layout-digest") + exactRef := "oci-layout://" + layoutPath + "@" + sourceDigest.String() + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", exactRef, "--prefer-index=false", source)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", exactRef, "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + require.Equal(t, sourceDigest, digest.FromBytes(dt)) + + wrongRef := "oci-layout://" + filepath.Join(dir, "layout-digest-wrong") + "@sha256:1111111111111111111111111111111111111111111111111111111111111111" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", wrongRef, "--prefer-index=false", source)) + dt, err = cmd.CombinedOutput() + require.Error(t, err, string(dt)) + require.Contains(t, string(dt), "requested digest") +} + // testImagetoolsAppend verifies create --append adds a new source onto an // existing target image and rewrites it as a combined index. func testImagetoolsAppend(t *testing.T, sb integration.Sandbox) { diff --git a/util/imagetools/auth.go b/util/imagetools/auth.go index f917fbdc8..f33c9b2c9 100644 --- a/util/imagetools/auth.go +++ b/util/imagetools/auth.go @@ -15,6 +15,13 @@ func RegistryAuthForRef(ref string, auth authprovider.AuthConfigProvider) (strin if auth == nil { return "", nil } + loc, err := ParseLocation(ref) + if err != nil { + return "", err + } + if loc.IsOCILayout() { + return "", nil + } r, err := parseRef(ref) if err != nil { return "", err diff --git a/util/imagetools/create.go b/util/imagetools/create.go index 979b3f779..0bf60b0b7 100644 --- a/util/imagetools/create.go +++ b/util/imagetools/create.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "maps" - "net/url" + "os" "strings" "github.com/containerd/containerd/v2/core/content" @@ -13,7 +13,7 @@ import ( "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/errdefs" "github.com/containerd/platforms" - "github.com/distribution/reference" + "github.com/moby/buildkit/client/ociindex" "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/util/attestation" "github.com/moby/buildkit/util/contentutil" @@ -38,7 +38,7 @@ var supportedArtifactTypes = map[string]struct{}{ type Source struct { Desc ocispecs.Descriptor - Ref reference.Named + Ref *Location } func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[exptypes.AnnotationKey]string, preferIndex bool, platforms []ocispecs.Platform) ([]byte, ocispecs.Descriptor, []DescWithSource, error) { @@ -60,7 +60,7 @@ func (r *Resolver) combine(ctx context.Context, srcs []*Source, ann map[exptypes for i := range dts { func(i int) { eg.Go(func() error { - dt, err := r.GetDescriptor(ctx, srcs[i].Ref.String(), srcs[i].Desc) + dt, err := r.GetDescriptor(ctx, srcs[i].Ref, srcs[i].Desc) if err != nil { return err } @@ -83,7 +83,7 @@ func (r *Resolver) combine(ctx context.Context, srcs []*Source, ann map[exptypes p = &ocispecs.Platform{} } if p.OS == "" || p.Architecture == "" { - if err := r.loadPlatform(ctx, p, srcs[i].Ref.String(), dt); err != nil { + if err := r.loadPlatform(ctx, srcs[i].Ref, p, dt); err != nil { return err } } @@ -219,14 +219,20 @@ func (r *Resolver) combine(ctx context.Context, srcs []*Source, ann map[exptypes }, sources, nil } -func (r *Resolver) Push(ctx context.Context, ref reference.Named, desc ocispecs.Descriptor, dt []byte) error { +func (r *Resolver) Push(ctx context.Context, ref *Location, desc ocispecs.Descriptor, dt []byte) error { ctx = remotes.WithMediaTypeKeyPrefix(ctx, "application/vnd.in-toto+json", "intoto") + if ref.IsOCILayout() { + if err := ref.ValidateTargetDigest(desc.Digest); err != nil { + return err + } + return r.pushOCILayout(ctx, ref, desc, dt) + } - fullRef, err := reference.WithDigest(reference.TagNameOnly(ref), desc.Digest) + fullRef, err := ref.WithDigest(desc.Digest) if err != nil { return errors.Wrapf(err, "failed to combine ref %s with digest %s", ref, desc.Digest) } - p, err := r.resolver().Pusher(ctx, fullRef.String()) + p, err := r.registryResolver().Pusher(ctx, fullRef.String()) if err != nil { return err } @@ -245,72 +251,59 @@ func (r *Resolver) Push(ctx context.Context, ref reference.Named, desc ocispecs. return err } -func (r *Resolver) Copy(ctx context.Context, src *Source, dest reference.Named) error { +func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error { ctx = remotes.WithMediaTypeKeyPrefix(ctx, "application/vnd.in-toto+json", "intoto") ctx = remotes.WithMediaTypeKeyPrefix(ctx, "application/vnd.oci.empty.v1+json", "empty") - // push by digest - p, err := r.resolver().Pusher(ctx, dest.Name()) - if err != nil { - return err - } - - srcRef := reference.TagNameOnly(src.Ref) - f, err := r.resolver().Fetcher(ctx, srcRef.String()) - if err != nil { - return err - } - - refspec := reference.TrimNamed(src.Ref).String() - u, err := url.Parse("dummy://" + refspec) - if err != nil { - return err - } - desc := src.Desc desc.Annotations = maps.Clone(desc.Annotations) if desc.Annotations == nil { desc.Annotations = make(map[string]string) } - - source, repo := u.Hostname(), strings.TrimPrefix(u.Path, "/") - desc.Annotations["containerd.io/distribution.source."+source] = repo - - referrersFetcher, ok := f.(remotes.ReferrersFetcher) - if !ok { - return errors.Errorf("fetcher for %s does not support referrers", src.Ref.String()) + if src.Ref.IsRegistry() { + desc.Annotations["containerd.io/distribution.source."+src.Ref.Named().Name()] = src.Ref.Named().Name() } - opts := []contentutil.CopyOption{ - contentutil.WithReferrers(referrersFunc(func(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { - descs, err := referrersFetcher.FetchReferrers(ctx, desc.Digest) - if err != nil { - return nil, err - } - var filtered []ocispecs.Descriptor - for _, d := range descs { - if _, ok := supportedArtifactTypes[d.ArtifactType]; ok { - filtered = append(filtered, d) - } - } - return filtered, nil - })), - } - - err = contentutil.CopyChain(ctx, contentutil.FromPusher(p), contentutil.FromFetcher(f), desc, opts...) + provider, err := r.providerForLocation(src.Ref) if err != nil { return err } + ingester, err := r.ingesterForLocation(dest) + if err != nil { + return err + } + + recorder := &recordingReferrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { + descs, err := r.FetchReferrers(ctx, src.Ref, subject.Digest) + if err != nil { + return nil, err + } + var filtered []ocispecs.Descriptor + for _, d := range descs { + if _, ok := supportedArtifactTypes[d.ArtifactType]; ok { + filtered = append(filtered, d) + } + } + return filtered, nil + })} + + err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(recorder)) + if err != nil { + return err + } + if dest.IsOCILayout() { + return r.writeRecordedReferrers(ctx, dest, recorder) + } return nil } -func (r *Resolver) loadPlatform(ctx context.Context, p2 *ocispecs.Platform, in string, dt []byte) error { +func (r *Resolver) loadPlatform(ctx context.Context, loc *Location, p2 *ocispecs.Platform, dt []byte) error { var manifest ocispecs.Manifest if err := json.Unmarshal(dt, &manifest); err != nil { return errors.WithStack(err) } - dt, err := r.GetDescriptor(ctx, in, manifest.Config) + dt, err := r.GetDescriptor(ctx, loc, manifest.Config) if err != nil { return err } @@ -450,15 +443,7 @@ func (r *Resolver) filterPlatforms(ctx context.Context, dt []byte, desc ocispecs } src = defaultSource } - f, err := r.resolver().Fetcher(ctx, src.Ref.String()) - if err != nil { - return nil, ocispecs.Descriptor{}, nil, err - } - rf, ok := f.(remotes.ReferrersFetcher) - if !ok { - return nil, ocispecs.Descriptor{}, nil, errors.Errorf("fetcher for %s does not support referrers", srcMap[d].Ref.String()) - } - refs, err := rf.FetchReferrers(ctx, d, remotes.WithReferrerArtifactTypes(artifactTypeAttestationManifest)) + refs, err := r.FetchReferrers(ctx, src.Ref, d, remotes.WithReferrerArtifactTypes(artifactTypeAttestationManifest)) if err != nil { if errors.Is(err, errdefs.ErrNotFound) { continue @@ -505,6 +490,116 @@ func (r *Resolver) filterPlatforms(ctx context.Context, dt []byte, desc ocispecs return idxBytes, desc, mfstsWithSource, nil } +type recordingReferrersProvider struct { + base referrersFunc + refs map[digest.Digest][]ocispecs.Descriptor +} + +func (r *recordingReferrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { + out, err := r.base(ctx, desc) + if err != nil { + return nil, err + } + if r.refs == nil { + r.refs = map[digest.Digest][]ocispecs.Descriptor{} + } + r.refs[desc.Digest] = append(r.refs[desc.Digest], out...) + return out, nil +} + +func (r *Resolver) ingesterForLocation(loc *Location) (content.Ingester, error) { + if loc.IsRegistry() { + p, err := r.registryResolver().Pusher(context.TODO(), loc.Name()) + if err != nil { + return nil, err + } + return contentutil.FromPusher(p), nil + } + return r.localStore(loc.OCILayout().Path) +} + +func (r *Resolver) providerForLocation(loc *Location) (content.Provider, error) { + if loc.IsRegistry() { + f, err := r.registryResolver().Fetcher(context.TODO(), loc.String()) + if err != nil { + return nil, err + } + return contentutil.FromFetcher(f), nil + } + return r.localStore(loc.OCILayout().Path) +} + +func (r *Resolver) pushOCILayout(ctx context.Context, ref *Location, desc ocispecs.Descriptor, dt []byte) error { + if err := os.MkdirAll(ref.OCILayout().Path, 0o755); err != nil { + return err + } + store, err := r.localStore(ref.OCILayout().Path) + if err != nil { + return err + } + w, err := store.Writer(ctx, content.WithRef(desc.Digest.String()), content.WithDescriptor(desc)) + if err != nil { + if errdefs.IsAlreadyExists(err) { + return nil + } + return err + } + err = content.Copy(ctx, w, bytes.NewReader(dt), desc.Size, desc.Digest) + if err != nil && !errdefs.IsAlreadyExists(err) { + return err + } + + idx := ociindex.NewStoreIndex(ref.OCILayout().Path) + switch { + case ref.Digest() != "": + return idx.Put(desc) + case ref.Tag() != "": + return idx.Put(desc, ociindex.Tag(ref.Tag())) + default: + return idx.Put(desc, ociindex.Tag("latest")) + } +} + +func (r *Resolver) writeRecordedReferrers(ctx context.Context, loc *Location, refs *recordingReferrersProvider) error { + if refs == nil || len(refs.refs) == 0 { + return nil + } + store, err := r.localStore(loc.OCILayout().Path) + if err != nil { + return err + } + idx := ociindex.NewStoreIndex(loc.OCILayout().Path) + for subject, manifests := range refs.refs { + fallback := ocispecs.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageIndex, + Manifests: manifests, + } + dt, err := json.Marshal(fallback) + if err != nil { + return err + } + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + w, err := store.Writer(ctx, content.WithRef(desc.Digest.String()), content.WithDescriptor(desc)) + if err != nil && !errdefs.IsAlreadyExists(err) { + return err + } + if err == nil { + if err := content.Copy(ctx, w, bytes.NewReader(dt), desc.Size, desc.Digest); err != nil && !errdefs.IsAlreadyExists(err) { + return err + } + } + if err := idx.Put(desc, ociindex.Tag("sha256-"+subject.Encoded())); err != nil { + return err + } + } + return nil +} + func detectMediaType(dt []byte) (string, error) { var mfst struct { MediaType string `json:"mediaType"` diff --git a/util/imagetools/inspect.go b/util/imagetools/inspect.go index 656654dc0..a178b0737 100644 --- a/util/imagetools/inspect.go +++ b/util/imagetools/inspect.go @@ -3,19 +3,27 @@ package imagetools import ( "bytes" "context" + "encoding/json" "io" "net/http" + "sync" + "github.com/containerd/containerd/v2/core/content" "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/remotes/docker" + contentlocal "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/distribution/reference" "github.com/docker/buildx/util/resolver" "github.com/docker/buildx/util/resolver/auth" + "github.com/moby/buildkit/client/ociindex" "github.com/moby/buildkit/session/auth/authprovider" "github.com/moby/buildkit/util/contentutil" "github.com/moby/buildkit/util/tracing" + digest "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -25,9 +33,11 @@ type Opt struct { } type Resolver struct { - auth docker.Authorizer - hosts docker.RegistryHosts - buffer contentutil.Buffer + auth docker.Authorizer + hosts docker.RegistryHosts + buffer contentutil.Buffer + localStoreMu sync.Mutex + localStores map[string]content.Store } func New(opt Opt) *Resolver { @@ -37,13 +47,14 @@ func New(opt Opt) *Resolver { AuthConfig: opt.Auth, } return &Resolver{ - auth: auth, - hosts: resolver.NewRegistryConfig(opt.RegistryConfig), - buffer: contentutil.NewBuffer(), + auth: auth, + hosts: resolver.NewRegistryConfig(opt.RegistryConfig), + buffer: contentutil.NewBuffer(), + localStores: map[string]content.Store{}, } } -func (r *Resolver) resolver() remotes.Resolver { +func (r *Resolver) registryResolver() remotes.Resolver { return docker.NewResolver(docker.ResolverOptions{ Hosts: func(domain string) ([]docker.RegistryHost, error) { res, err := r.hosts(domain) @@ -66,12 +77,19 @@ func (r *Resolver) Resolve(ctx context.Context, in string) (string, ocispecs.Des logger.Out = io.Discard ctx = log.WithLogger(ctx, logrus.NewEntry(logger)) + loc, err := ParseLocation(in) + if err != nil { + return "", ocispecs.Descriptor{}, err + } + if loc.IsOCILayout() { + return r.resolveOCILayout(ctx, loc) + } + ref, err := parseRef(in) if err != nil { return "", ocispecs.Descriptor{}, err } - - in, desc, err := r.resolver().Resolve(ctx, ref.String()) + in, desc, err := r.registryResolver().Resolve(ctx, ref.String()) if err != nil { return "", ocispecs.Descriptor{}, err } @@ -85,15 +103,19 @@ func (r *Resolver) Get(ctx context.Context, in string) ([]byte, ocispecs.Descrip return nil, ocispecs.Descriptor{}, err } - dt, err := r.GetDescriptor(ctx, in, desc) + loc, err := ParseLocation(in) + if err != nil { + return nil, ocispecs.Descriptor{}, err + } + dt, err := r.GetDescriptor(ctx, loc, desc) if err != nil { return nil, ocispecs.Descriptor{}, err } return dt, desc, nil } -func (r *Resolver) GetDescriptor(ctx context.Context, in string, desc ocispecs.Descriptor) ([]byte, error) { - fetcher, err := r.resolver().Fetcher(ctx, in) +func (r *Resolver) GetDescriptor(ctx context.Context, loc *Location, desc ocispecs.Descriptor) ([]byte, error) { + fetcher, err := r.fetcherForLocation(ctx, loc) if err != nil { return nil, err } @@ -113,6 +135,143 @@ func (r *Resolver) GetDescriptor(ctx context.Context, in string, desc ocispecs.D return buf.Bytes(), nil } +func (r *Resolver) Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) { + loc, err := ParseLocation(ref) + if err != nil { + return nil, err + } + return r.fetcherForLocation(ctx, loc) +} + +func (r *Resolver) fetcherForLocation(ctx context.Context, loc *Location) (remotes.Fetcher, error) { + if loc.IsRegistry() { + return r.registryResolver().Fetcher(ctx, loc.String()) + } + store, err := r.localStore(loc.OCILayout().Path) + if err != nil { + return nil, err + } + return &providerFetcher{Provider: store}, nil +} + +func (r *Resolver) localStore(path string) (content.Store, error) { + r.localStoreMu.Lock() + defer r.localStoreMu.Unlock() + + if store, ok := r.localStores[path]; ok { + return store, nil + } + + store, err := contentlocal.NewStore(path) + if err != nil { + return nil, err + } + r.localStores[path] = store + return store, nil +} + +func (r *Resolver) FetchReferrers(ctx context.Context, loc *Location, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispecs.Descriptor, error) { + if loc.IsOCILayout() { + return r.fetchOCILayoutReferrers(ctx, loc, dgst) + } + f, err := r.registryResolver().Fetcher(ctx, loc.String()) + if err != nil { + return nil, err + } + rf, ok := f.(remotes.ReferrersFetcher) + if !ok { + return nil, errors.Errorf("fetcher for %s does not support referrers", loc.String()) + } + return rf.FetchReferrers(ctx, dgst, opts...) +} + +type providerFetcher struct { + content.Provider +} + +func (f *providerFetcher) Fetch(ctx context.Context, desc ocispecs.Descriptor) (io.ReadCloser, error) { + ra, err := f.ReaderAt(ctx, desc) + if err != nil { + return nil, err + } + return &readerAtReadCloser{ReaderAt: ra}, nil +} + +type readerAtReadCloser struct { + content.ReaderAt + offset int64 +} + +func (r *readerAtReadCloser) Read(dt []byte) (int, error) { + n, err := r.ReadAt(dt, r.offset) + r.offset += int64(n) + if n > 0 && errors.Is(err, io.EOF) { + return n, nil + } + return n, err +} + +func (r *readerAtReadCloser) Close() error { + return r.ReaderAt.Close() +} + +func (r *Resolver) resolveOCILayout(ctx context.Context, loc *Location) (string, ocispecs.Descriptor, error) { + store, err := r.localStore(loc.OCILayout().Path) + if err != nil { + return "", ocispecs.Descriptor{}, err + } + if loc.Digest() != "" { + info, err := store.Info(ctx, loc.Digest()) + if err != nil { + return "", ocispecs.Descriptor{}, err + } + desc := ocispecs.Descriptor{Digest: info.Digest, Size: info.Size} + dt, err := content.ReadBlob(ctx, store, desc) + if err != nil { + return "", ocispecs.Descriptor{}, err + } + mt, err := detectMediaType(dt) + if err != nil { + return "", ocispecs.Descriptor{}, err + } + desc.MediaType = mt + return loc.String(), desc, nil + } + idx := ociindex.NewStoreIndex(loc.OCILayout().Path) + desc, err := idx.Get(loc.Tag()) + if err != nil { + return "", ocispecs.Descriptor{}, err + } + if desc == nil { + return "", ocispecs.Descriptor{}, errors.Wrapf(errdefs.ErrNotFound, "reference %s not found", loc.String()) + } + return loc.String(), *desc, nil +} + +func (r *Resolver) fetchOCILayoutReferrers(ctx context.Context, loc *Location, dgst digest.Digest) ([]ocispecs.Descriptor, error) { + idx := ociindex.NewStoreIndex(loc.OCILayout().Path) + // TODO: temporary fallback tag, should use annotations instead + desc, err := idx.Get("sha256-" + dgst.Encoded()) + if err != nil { + return nil, err + } + if desc == nil { + return nil, errors.WithStack(errdefs.ErrNotFound) + } + dt, err := r.GetDescriptor(ctx, loc, *desc) + if err != nil { + return nil, err + } + if desc.MediaType != ocispecs.MediaTypeImageIndex { + return nil, errors.Errorf("unsupported referrers media type %s", desc.MediaType) + } + var referrersIndex ocispecs.Index + if err := json.Unmarshal(dt, &referrersIndex); err != nil { + return nil, err + } + return referrersIndex.Manifests, nil +} + func parseRef(s string) (reference.Named, error) { ref, err := reference.ParseNormalizedNamed(s) if err != nil { diff --git a/util/imagetools/loader.go b/util/imagetools/loader.go index 3650bcaf3..d30fd49b6 100644 --- a/util/imagetools/loader.go +++ b/util/imagetools/loader.go @@ -16,7 +16,6 @@ import ( "github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/platforms" - "github.com/distribution/reference" intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/moby/buildkit/util/contentutil" "github.com/opencontainers/go-digest" @@ -43,8 +42,13 @@ type contentCache interface { content.Ingester } +type loaderResolver interface { + Resolve(ctx context.Context, ref string) (string, ocispecs.Descriptor, error) + Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) +} + type loader struct { - resolver remotes.Resolver + resolver loaderResolver cache contentCache } @@ -78,7 +82,7 @@ type result struct { assets map[string]asset } -func newLoader(resolver remotes.Resolver) *loader { +func newLoader(resolver loaderResolver) *loader { return &loader{ resolver: resolver, cache: contentutil.NewBuffer(), @@ -86,22 +90,17 @@ func newLoader(resolver remotes.Resolver) *loader { } func (l *loader) Load(ctx context.Context, ref string) (*result, error) { - named, err := parseRef(ref) + loc, err := ParseLocation(ref) if err != nil { return nil, err } - _, desc, err := l.resolver.Resolve(ctx, named.String()) + _, desc, err := l.resolver.Resolve(ctx, loc.String()) if err != nil { return nil, err } - canonical, err := reference.WithDigest(named, desc.Digest) - if err != nil { - return nil, err - } - - fetcher, err := l.resolver.Fetcher(ctx, canonical.String()) + fetcher, err := l.resolver.Fetcher(ctx, loc.String()) if err != nil { return nil, err } diff --git a/util/imagetools/location.go b/util/imagetools/location.go new file mode 100644 index 000000000..737f3044d --- /dev/null +++ b/util/imagetools/location.go @@ -0,0 +1,187 @@ +package imagetools + +import ( + "strings" + + "github.com/distribution/reference" + "github.com/docker/buildx/util/ocilayout" + digest "github.com/opencontainers/go-digest" + "github.com/pkg/errors" +) + +type LocationKind int + +const ( + LocationKindRegistry LocationKind = iota + LocationKindOCILayout +) + +type Location struct { + kind LocationKind + + original string + + named reference.Named + oci ocilayout.Ref +} + +func ParseLocation(s string) (*Location, error) { + if ref, ok, err := ocilayout.Parse(s); ok { + if err != nil { + return nil, err + } + return &Location{ + kind: LocationKindOCILayout, + original: s, + oci: ref, + }, nil + } + + ref, err := reference.ParseNormalizedNamed(s) + if err != nil { + return nil, err + } + ref = reference.TagNameOnly(ref) + return &Location{ + kind: LocationKindRegistry, + original: s, + named: ref, + }, nil +} + +func (l *Location) String() string { + if l == nil { + return "" + } + switch l.kind { + case LocationKindOCILayout: + return l.oci.String() + default: + return l.named.String() + } +} + +func (l *Location) Kind() LocationKind { + return l.kind +} + +func (l *Location) IsRegistry() bool { + return l != nil && l.kind == LocationKindRegistry +} + +func (l *Location) IsOCILayout() bool { + return l != nil && l.kind == LocationKindOCILayout +} + +func (l *Location) Name() string { + if l == nil { + return "" + } + if l.IsRegistry() { + return l.named.Name() + } + return l.oci.Path +} + +func (l *Location) Named() reference.Named { + if l == nil { + return nil + } + return l.named +} + +func (l *Location) OCILayout() ocilayout.Ref { + return l.oci +} + +func (l *Location) Tag() string { + if l == nil { + return "" + } + if l.IsRegistry() { + if tagged, ok := l.named.(reference.Tagged); ok { + return tagged.Tag() + } + return "" + } + return l.oci.Tag +} + +func (l *Location) Digest() digest.Digest { + if l == nil { + return "" + } + if l.IsRegistry() { + if digested, ok := l.named.(reference.Digested); ok { + return digested.Digest() + } + return "" + } + return l.oci.Digest +} + +func (l *Location) WithDigest(dgst digest.Digest) (*Location, error) { + if l.IsRegistry() { + d, err := reference.WithDigest(l.named, dgst) + if err != nil { + return nil, err + } + return &Location{kind: LocationKindRegistry, original: d.String(), named: d}, nil + } + ref := l.oci + ref.Tag = "" + ref.Digest = dgst + return &Location{kind: LocationKindOCILayout, original: ref.String(), oci: ref}, nil +} + +func (l *Location) WithTag(tag string) (*Location, error) { + if l.IsRegistry() { + n, err := reference.ParseNormalizedNamed(l.named.Name()) + if err != nil { + return nil, err + } + t, err := reference.WithTag(n, tag) + if err != nil { + return nil, err + } + return &Location{kind: LocationKindRegistry, original: t.String(), named: t}, nil + } + ref := l.oci + ref.Tag = tag + ref.Digest = "" + return &Location{kind: LocationKindOCILayout, original: ref.String(), oci: ref}, nil +} + +func (l *Location) TagNameOnly() (*Location, error) { + if l.IsRegistry() { + n, err := reference.ParseNormalizedNamed(l.named.Name()) + if err != nil { + return nil, err + } + return &Location{ + kind: LocationKindRegistry, + original: reference.TagNameOnly(n).String(), + named: reference.TagNameOnly(n), + }, nil + } + ref := l.oci + ref.Digest = "" + if ref.Tag == "" { + ref.Tag = "latest" + } + return &Location{kind: LocationKindOCILayout, original: ref.String(), oci: ref}, nil +} + +func (l *Location) ValidateTargetDigest(desc digest.Digest) error { + if l == nil || l.Digest() == "" { + return nil + } + if l.Digest() != desc { + return errors.Errorf("target %s requested digest %s but produced %s", l.String(), l.Digest(), desc) + } + return nil +} + +func IsOCILayout(s string) bool { + return strings.HasPrefix(s, "oci-layout://") +} diff --git a/util/imagetools/printers.go b/util/imagetools/printers.go index 2d3e795a4..0504abf9a 100644 --- a/util/imagetools/printers.go +++ b/util/imagetools/printers.go @@ -12,7 +12,6 @@ import ( "github.com/containerd/containerd/v2/core/images" "github.com/containerd/platforms" - "github.com/distribution/reference" "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" ) @@ -27,7 +26,7 @@ type Printer struct { format string raw []byte - ref reference.Named + ref *Location manifest ocispecs.Descriptor index ocispecs.Index } @@ -35,7 +34,7 @@ type Printer struct { func NewPrinter(ctx context.Context, opt Opt, name string, format string) (*Printer, error) { resolver := New(opt) - ref, err := parseRef(name) + ref, err := ParseLocation(name) if err != nil { return nil, err } @@ -83,7 +82,7 @@ func (p *Printer) Print(raw bool, out io.Writer) error { return nil } - res, err := newLoader(p.resolver.resolver()).Load(p.ctx, p.name) + res, err := newLoader(p.resolver).Load(p.ctx, p.name) if err != nil { return err } @@ -125,11 +124,11 @@ func (p *Printer) Print(raw bool, out io.Writer) error { switch { // TODO: print formatted config - case strings.HasPrefix(format, "{{.Manifest"): + case isWholeManifestTemplate(format): w := tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) _, _ = fmt.Fprintf(w, "Name:\t%s\n", p.ref.String()) switch { - case strings.HasPrefix(format, "{{.Manifest"): + case isWholeManifestTemplate(format): _, _ = fmt.Fprintf(w, "MediaType:\t%s\n", p.manifest.MediaType) _, _ = fmt.Fprintf(w, "Digest:\t%s\n", p.manifest.Digest) _ = w.Flush() @@ -162,6 +161,10 @@ func (p *Printer) Print(raw bool, out io.Writer) error { return nil } +func isWholeManifestTemplate(format string) bool { + return strings.TrimSpace(format) == "{{.Manifest}}" +} + func (p *Printer) printManifestList(out io.Writer) error { w := tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) _, _ = fmt.Fprintf(w, "\t\n") @@ -173,11 +176,11 @@ func (p *Printer) printManifestList(out io.Writer) error { if i != 0 { _, _ = fmt.Fprintf(w, "\t\n") } - cr, err := reference.WithDigest(p.ref, m.Digest) - if err != nil { - return err + name := p.ref.String() + if ref, err := p.ref.WithDigest(m.Digest); err == nil { + name = ref.String() } - _, _ = fmt.Fprintf(w, "%sName:\t%s\n", defaultPfx, cr.String()) + _, _ = fmt.Fprintf(w, "%sName:\t%s\n", defaultPfx, name) _, _ = fmt.Fprintf(w, "%sMediaType:\t%s\n", defaultPfx, m.MediaType) if p := m.Platform; p != nil { _, _ = fmt.Fprintf(w, "%sPlatform:\t%s\n", defaultPfx, platforms.Format(*p)) diff --git a/util/ocilayout/parse.go b/util/ocilayout/parse.go new file mode 100644 index 000000000..87796f7e0 --- /dev/null +++ b/util/ocilayout/parse.go @@ -0,0 +1,60 @@ +package ocilayout + +import ( + "strings" + + "github.com/distribution/reference" + digest "github.com/opencontainers/go-digest" +) + +type Ref struct { + Path string + Tag string + Digest digest.Digest +} + +const prefix = "oci-layout://" + +func Parse(s string) (Ref, bool, error) { + if !strings.HasPrefix(s, prefix) { + return Ref{}, false, nil + } + + localPath := strings.TrimPrefix(s, prefix) + var out Ref + + if i := strings.LastIndex(localPath, "@"); i >= 0 { + after := localPath[i+1:] + if reference.DigestRegexp.MatchString(after) { + dgst, err := digest.Parse(after) + if err != nil { + return Ref{}, true, err + } + localPath, out.Digest = localPath[:i], dgst + } + } + + if i := strings.LastIndex(localPath, ":"); i >= 0 { + after := localPath[i+1:] + if reference.TagRegexp.MatchString(after) { + localPath, out.Tag = localPath[:i], after + } + } + + out.Path = localPath + if out.Tag == "" && out.Digest == "" { + out.Tag = "latest" + } + return out, true, nil +} + +func (r Ref) String() string { + s := prefix + r.Path + if r.Tag != "" { + return s + ":" + r.Tag + } + if r.Digest != "" { + return s + "@" + r.Digest.String() + } + return s +} diff --git a/util/ocilayout/parse_test.go b/util/ocilayout/parse_test.go new file mode 100644 index 000000000..1370d512d --- /dev/null +++ b/util/ocilayout/parse_test.go @@ -0,0 +1,50 @@ +package ocilayout + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + for _, tt := range []struct { + s string + path string + dgst string + tag string + }{ + { + s: "oci-layout:///path/to/oci/layout", + path: "/path/to/oci/layout", + tag: "latest", + }, + { + s: "oci-layout:///path/to/oci/layout:1.3", + path: "/path/to/oci/layout", + tag: "1.3", + }, + { + s: "oci-layout:///path/to/oci/layout@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + path: "/path/to/oci/layout", + dgst: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + s: "oci-layout:///path/to/oci/@/layout@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + path: "/path/to/oci/@/layout", + dgst: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + s: "oci-layout:///path/to/oci/@/layout", + path: "/path/to/oci/@/layout", + tag: "latest", + }, + } { + ref, ok, err := Parse(tt.s) + require.True(t, ok) + require.NoError(t, err) + assert.Equal(t, tt.path, ref.Path, "comparing path: %s", tt.s) + assert.Equal(t, tt.dgst, ref.Digest.String(), "comparing digest: %s", tt.s) + assert.Equal(t, tt.tag, ref.Tag, "comparing tag: %s", tt.s) + } +}