From 0a0c41d1bb1125469e7567cd86e2c63b5d0a7ae6 Mon Sep 17 00:00:00 2001 From: Jeff Carter Date: Thu, 28 May 2026 09:45:02 -0400 Subject: [PATCH] implement ocilayout implementation for oci.Interface --- README.md | 1 + ocilayout/README.md | 94 ++++++++++ ocilayout/blob.go | 179 +++++++++++++++++++ ocilayout/deleter.go | 121 +++++++++++++ ocilayout/find.go | 156 +++++++++++++++++ ocilayout/find_test.go | 135 +++++++++++++++ ocilayout/layout.go | 283 ++++++++++++++++++++++++++++++ ocilayout/layout_test.go | 307 +++++++++++++++++++++++++++++++++ ocilayout/lister.go | 151 ++++++++++++++++ ocilayout/per_repository.go | 299 ++++++++++++++++++++++++++++++++ ocilayout/reader.go | 146 ++++++++++++++++ ocilayout/refs.go | 196 +++++++++++++++++++++ ocilayout/writer.go | 334 ++++++++++++++++++++++++++++++++++++ 13 files changed, 2402 insertions(+) create mode 100644 ocilayout/README.md create mode 100644 ocilayout/blob.go create mode 100644 ocilayout/deleter.go create mode 100644 ocilayout/find.go create mode 100644 ocilayout/find_test.go create mode 100644 ocilayout/layout.go create mode 100644 ocilayout/layout_test.go create mode 100644 ocilayout/lister.go create mode 100644 ocilayout/per_repository.go create mode 100644 ocilayout/reader.go create mode 100644 ocilayout/refs.go create mode 100644 ocilayout/writer.go diff --git a/README.md b/README.md index 5094e14..38f8766 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ used to interact with those features. | `ociclient` | HTTP client that implements `oci.Interface` against a remote OCI registry. | | `ociserver` | HTTP server that serves the OCI distribution protocol on top of any `oci.Interface`. | | `ocimem` | Lightweight in-memory `oci.Interface` implementation, useful for testing and caching. | +| `ocilayout` | Filesystem-backed `oci.Interface` implementation for OCI Image Layout directories, including shared and per-repository layouts. | | `ociauth` | Authentication transport implementing the Docker/OCI token flow, plus helpers for loading credentials from Docker config files. | | `ocifilter` | Wrappers that expose restricted or transformed views of a registry (read-only, immutable, namespace prefix, custom access control). | | `ociunify` | Combines two registries into a single unified `oci.Interface`, with configurable read policy. | diff --git a/ocilayout/README.md b/ocilayout/README.md new file mode 100644 index 0000000..8e670ef --- /dev/null +++ b/ocilayout/README.md @@ -0,0 +1,94 @@ +# ocilayout + +Package `ocilayout` provides an `oci.Interface` implementation backed by an +[OCI Image Layout](https://github.com/opencontainers/image-spec/blob/main/image-layout.md) +directory on disk. + +Use it when you want registry-like reads and writes without running a registry +server. It stores manifests in `index.json`, blobs under `blobs/`, and records +named references with the standard `org.opencontainers.image.ref.name` +annotation. + +## Shared Layout + +`New` opens a single OCI layout directory that can hold multiple repositories. +Missing layout files are not created when the registry is opened; write +operations create `oci-layout`, `index.json`, and blob directories lazily. + +```go +package main + +import ( + "context" + "fmt" + + "github.com/docker/oci" + "github.com/docker/oci/ocilayout" +) + +func main() { + reg, err := ocilayout.New("./layout", &ocilayout.Options{}) + if err != nil { + panic(err) + } + + tags, err := oci.All(reg.Tags(context.Background(), "example/app", nil)) + if err != nil { + panic(err) + } + fmt.Println(tags) +} +``` + +When reading existing layouts that use tag-only `ref.name` annotations, set +`DefaultRepo` so those entries can be associated with a repository. + +```go +reg, err := ocilayout.New("./layout", &ocilayout.Options{ + DefaultRepo: "example/app", +}) +``` + +## Per-Repository Layouts + +`NewPerRepository` stores each repository in its own nested OCI layout under the +given directory. For repository `example/app`, the underlying layout lives at +`/example/app`. + +```go +reg, err := ocilayout.NewPerRepository("./layouts", &ocilayout.PerRepoOptions{}) +if err != nil { + panic(err) +} +``` + +The per-repository constructor has its own options type so its API can evolve +independently from `New`. + +## Finding Layouts From Paths + +`FindLayout` splits a user-supplied path into the base directory for `New` and +the image reference suffix. It first looks for `oci-layout` marker files in path +prefixes and uses the deepest matching layout. If no marker exists, it falls +back to treating the last path component as the reference. + +```go +baseDir, ref, err := ocilayout.FindLayout("./foo/bar:baz") +// baseDir == "./foo" +// ref.Repository == "bar" +// ref.Tag == "baz" +``` + +```go +baseDir, ref, err := ocilayout.FindLayout("./one/two/three/four:tag") +// If ./one/two/oci-layout exists: +// baseDir == "./one/two" +// ref.Repository == "three/four" +// ref.Tag == "tag" +``` + +References may include tags, digests, or both: + +```go +baseDir, ref, err := ocilayout.FindLayout("./layout/repo:tag@sha256:...") +``` diff --git a/ocilayout/blob.go b/ocilayout/blob.go new file mode 100644 index 0000000..822c6dd --- /dev/null +++ b/ocilayout/blob.go @@ -0,0 +1,179 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/docker/oci" + "github.com/docker/oci/ocidigest" +) + +func blobPath(dir string, digest oci.Digest) (string, error) { + if err := digest.Validate(); err != nil { + return "", fmt.Errorf("%w: %v", oci.ErrDigestInvalid, err) + } + alg := digest.Algorithm().String() + enc := digest.Encoded() + if alg == "" || enc == "" { + return "", oci.ErrDigestInvalid + } + return filepath.Join(dir, "blobs", alg, enc), nil +} + +func ensureBlobExists(dir string, digest oci.Digest) error { + path, err := blobPath(dir, digest) + if err != nil { + return err + } + _, err = os.Stat(path) // #nosec G703 -- path is derived from a validated OCI digest. + return err +} + +func writeBlob(dir string, desc oci.Descriptor, r io.Reader) (oci.Descriptor, error) { + if desc.Size < 0 { + return oci.Descriptor{}, oci.ErrSizeInvalid + } + final, err := blobPath(dir, desc.Digest) + if err != nil { + return oci.Descriptor{}, err + } + if existing, err := os.Stat(final); err == nil { // #nosec G703 -- path is derived from a validated OCI digest. + if existing.Size() != desc.Size { + return oci.Descriptor{}, oci.ErrSizeInvalid + } + return desc, nil + } else if !os.IsNotExist(err) { + return oci.Descriptor{}, err + } + if err := os.MkdirAll(filepath.Join(dir, "blobs", "uploads"), 0o700); err != nil { + return oci.Descriptor{}, err + } + tmp := filepath.Join(dir, "blobs", "uploads", newUploadID()) + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return oci.Descriptor{}, err + } + cleanup := true + defer func() { + f.Close() + if cleanup { + os.Remove(tmp) + } + }() + dw, err := ocidigest.NewWriter(f, desc.Digest.Algorithm()) + if err != nil { + return oci.Descriptor{}, err + } + if _, err := io.Copy(dw, r); err != nil { + return oci.Descriptor{}, err + } + got, err := dw.Digest() + if err != nil { + return oci.Descriptor{}, err + } + if got != desc.Digest { + return oci.Descriptor{}, fmt.Errorf("digest mismatch: %w", oci.ErrDigestInvalid) + } + if dw.Size() != desc.Size { + return oci.Descriptor{}, fmt.Errorf("size mismatch: %w", oci.ErrSizeInvalid) + } + if err := f.Close(); err != nil { + return oci.Descriptor{}, err + } + if err := os.MkdirAll(filepath.Dir(final), 0o700); err != nil { // #nosec G703 -- path is derived from a validated OCI digest. + return oci.Descriptor{}, err + } + if err := os.Rename(tmp, final); err != nil { // #nosec G703 -- final path is derived from a validated OCI digest. + return oci.Descriptor{}, err + } + cleanup = false + return desc, nil +} + +func writeBlobBytes(dir string, desc oci.Descriptor, data []byte) (oci.Descriptor, error) { + return writeBlob(dir, desc, bytes.NewReader(data)) +} + +type layoutReader struct { + *os.File + desc oci.Descriptor +} + +func (r *layoutReader) Descriptor() oci.Descriptor { + return r.desc +} + +type sectionReadCloser struct { + *io.SectionReader + f *os.File + desc oci.Descriptor +} + +func (r *sectionReadCloser) Close() error { + return r.f.Close() +} + +func (r *sectionReadCloser) Descriptor() oci.Descriptor { + return r.desc +} + +func openBlob(dir string, desc oci.Descriptor) (oci.BlobReader, error) { + path, err := blobPath(dir, desc.Digest) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + return &layoutReader{File: f, desc: desc}, nil +} + +func openBlobRange(dir string, desc oci.Descriptor, o0, o1 int64) (oci.BlobReader, error) { + if o1 < 0 || o1 > desc.Size { + o1 = desc.Size + } + if o0 < 0 || o0 > o1 { + return nil, fmt.Errorf("invalid range [%d, %d]; have [%d, %d]: %w", o0, o1, 0, desc.Size, oci.ErrRangeInvalid) + } + path, err := blobPath(dir, desc.Digest) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + return §ionReadCloser{ + SectionReader: io.NewSectionReader(f, o0, o1-o0), + f: f, + desc: desc, + }, nil +} + +func newUploadID() string { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + panic(err) + } + return hex.EncodeToString(buf[:]) +} diff --git a/ocilayout/deleter.go b/ocilayout/deleter.go new file mode 100644 index 0000000..ebf5be4 --- /dev/null +++ b/ocilayout/deleter.go @@ -0,0 +1,121 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "context" + "fmt" + + "github.com/docker/oci" +) + +// DeleteBlob deletes the blob with the given digest from the named repository. +func (r *Registry) DeleteBlob(ctx context.Context, repo string, digest oci.Digest) error { + if err := contextErr(ctx); err != nil { + return err + } + if _, _, err := r.resolveBlob(ctx, repo, digest); err != nil { + return err + } + return fmt.Errorf("%w: blob garbage collection is not implemented", oci.ErrDenied) +} + +// DeleteManifest deletes top-level index entries for the manifest digest. +func (r *Registry) DeleteManifest(ctx context.Context, repo string, digest oci.Digest) error { + if err := contextErr(ctx); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return err + } + refs, err := r.refsForRepo(st, repo) + if err != nil { + return err + } + if len(refs) == 0 { + return oci.ErrNameUnknown + } + found := false + out := st.index.Manifests[:0] + for _, desc := range st.index.Manifests { + match, err := r.refDescriptorMatches(repo, "", digest, desc) + if err != nil { + return err + } + if match { + found = true + continue + } + out = append(out, desc) + } + if !found { + return oci.ErrManifestUnknown + } + st.index.Manifests = out + return saveIndex(st.dir, st.index) +} + +// DeleteTag deletes the given tag from the named repository. +func (r *Registry) DeleteTag(ctx context.Context, repo string, tagName string) error { + if err := contextErr(ctx); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return err + } + if _, err := r.tagDescriptor(st, repo, tagName); err != nil { + return err + } + out := st.index.Manifests[:0] + for _, desc := range st.index.Manifests { + match, err := r.refDescriptorMatches(repo, tagName, "", desc) + if err != nil { + return err + } + if match { + continue + } + out = append(out, desc) + } + st.index.Manifests = out + return saveIndex(st.dir, st.index) +} + +func (r *Registry) refDescriptorMatches(repo string, tag string, digest oci.Digest, desc oci.Descriptor) (bool, error) { + ref := "" + if desc.Annotations != nil { + ref = desc.Annotations[refNameAnnotation] + } + gotRepo, gotTag, err := r.parseRef(ref) + if err != nil { + return false, err + } + if gotRepo != repo { + return false, nil + } + if tag != "" && gotTag != tag { + return false, nil + } + if digest != "" && desc.Digest != digest { + return false, nil + } + return true, nil +} diff --git a/ocilayout/find.go b/ocilayout/find.go new file mode 100644 index 0000000..755d500 --- /dev/null +++ b/ocilayout/find.go @@ -0,0 +1,156 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/docker/oci/ocidigest" + "github.com/docker/oci/ociref" +) + +// FindLayout splits path into an OCI image layout directory and image +// reference. +// +// It first looks for OCI layout marker files in directory prefixes of path. If +// one or more marker files are found, the deepest directory whose remaining +// suffix parses as a reference is used as the base directory. If no marker file +// matches, FindLayout falls back to treating the last path component as the +// reference and the preceding path as the base directory. +func FindLayout(path string) (baseDir string, ref ociref.Reference, err error) { + if path == "" { + return "", ociref.Reference{}, fmt.Errorf("path must not be empty") + } + + candidates := layoutCandidates(path) + for i := len(candidates) - 1; i >= 0; i-- { + c := candidates[i] + ok, err := hasLayoutFile(c.baseDir) + if err != nil { + return "", ociref.Reference{}, err + } + if !ok { + continue + } + ref, err := parseLayoutReference(filepath.ToSlash(c.ref)) + if err != nil { + continue + } + return c.baseDir, ref, nil + } + + fallback := candidates[len(candidates)-1] + ref, err = parseLayoutReference(filepath.ToSlash(fallback.ref)) + if err != nil { + return "", ociref.Reference{}, err + } + return fallback.baseDir, ref, nil +} + +func parseLayoutReference(refStr string) (ociref.Reference, error) { + var ref ociref.Reference + repoAndTag, digestStr, hasDigest := strings.Cut(refStr, "@") + if hasDigest { + if digestStr == "" { + return ociref.Reference{}, fmt.Errorf("invalid reference syntax (%q)", refStr) + } + digest, err := ocidigest.Parse(digestStr) + if err != nil { + return ociref.Reference{}, fmt.Errorf("invalid digest %q: %v", digestStr, err) + } + if err := digest.Validate(); err != nil { + return ociref.Reference{}, fmt.Errorf("invalid digest %q: %v", digest, err) + } + ref.Digest = digest + } + repo, tag, hasTag := strings.Cut(repoAndTag, ":") + if hasTag { + if strings.Contains(tag, ":") || strings.Contains(tag, "/") { + return ociref.Reference{}, fmt.Errorf("invalid reference syntax (%q)", refStr) + } + if !ociref.IsValidTag(tag) { + return ociref.Reference{}, fmt.Errorf("invalid tag %q", tag) + } + ref.Tag = tag + } + if !ociref.IsValidRepository(repo) { + return ociref.Reference{}, fmt.Errorf("invalid reference syntax (%q)", refStr) + } + if len(repo) > 255 { + return ociref.Reference{}, fmt.Errorf("repository name too long") + } + ref.Repository = repo + return ref, nil +} + +type layoutCandidate struct { + baseDir string + ref string +} + +func layoutCandidates(path string) []layoutCandidate { + var candidates []layoutCandidate + if !filepath.IsAbs(path) { + candidates = append(candidates, layoutCandidate{ + baseDir: ".", + ref: path, + }) + } + for i := range len(path) { + if !isPathSeparator(path[i]) || i == len(path)-1 { + continue + } + candidates = append(candidates, layoutCandidate{ + baseDir: baseDirForSplit(path, i), + ref: path[i+1:], + }) + } + if len(candidates) == 0 { + return []layoutCandidate{{ + baseDir: ".", + ref: path, + }} + } + return candidates +} + +func isPathSeparator(c byte) bool { + return c == '/' || c == filepath.Separator +} + +func baseDirForSplit(path string, sep int) string { + if sep == 0 { + return path[:1] + } + base := path[:sep] + if base == "." || base == ".." { + return path[:sep+1] + } + return base +} + +func hasLayoutFile(dir string) (bool, error) { + info, err := os.Stat(filepath.Join(dir, "oci-layout")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return !info.IsDir(), nil +} diff --git a/ocilayout/find_test.go b/ocilayout/find_test.go new file mode 100644 index 0000000..d906f6d --- /dev/null +++ b/ocilayout/find_test.go @@ -0,0 +1,135 @@ +package ocilayout + +import ( + "path/filepath" + "testing" + + "github.com/docker/oci/ocidigest" + "github.com/docker/oci/ociref" + "github.com/stretchr/testify/require" +) + +func TestFindLayoutFallbackSplitsLastPathComponent(t *testing.T) { + tests := []struct { + name string + path string + baseDir string + wantRepo string + wantTag string + }{{ + name: "nested path", + path: "./foo/bar:baz", + baseDir: "./foo", + wantRepo: "bar", + wantTag: "baz", + }, { + name: "single component relative dot slash", + path: "./foo:bar", + baseDir: "./", + wantRepo: "foo", + wantTag: "bar", + }, { + name: "single component", + path: "foo:bar", + baseDir: ".", + wantRepo: "foo", + wantTag: "bar", + }} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + baseDir, ref, err := FindLayout(test.path) + require.NoError(t, err) + require.Equal(t, test.baseDir, baseDir) + requireRef(t, ref, test.wantRepo, test.wantTag) + }) + } +} + +func TestFindLayoutUsesDeepestLayoutMarker(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + require.NoError(t, ensureLayout(filepath.Join("one", "two"))) + + baseDir, ref, err := FindLayout("./one/two/three/four:tag") + require.NoError(t, err) + require.Equal(t, "./one/two", baseDir) + requireRef(t, ref, "three/four", "tag") +} + +func TestFindLayoutPrefersDeepestLayoutMarker(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + require.NoError(t, ensureLayout("one")) + require.NoError(t, ensureLayout(filepath.Join("one", "two"))) + + baseDir, ref, err := FindLayout("./one/two/three/four:tag") + require.NoError(t, err) + require.Equal(t, "./one/two", baseDir) + requireRef(t, ref, "three/four", "tag") +} + +func TestFindLayoutUsesCurrentLayoutMarker(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + require.NoError(t, ensureLayout(".")) + + baseDir, ref, err := FindLayout("one/two:tag") + require.NoError(t, err) + require.Equal(t, ".", baseDir) + requireRef(t, ref, "one/two", "tag") +} + +func TestFindLayoutParsesDigestReference(t *testing.T) { + digest := ocidigest.FromBytes([]byte("manifest")) + + baseDir, ref, err := FindLayout("./layout/repo:tag@" + digest.String()) + require.NoError(t, err) + require.Equal(t, "./layout", baseDir) + require.Empty(t, ref.Host) + require.Equal(t, "repo", ref.Repository) + require.Equal(t, "tag", ref.Tag) + require.Equal(t, digest, ref.Digest) +} + +func TestFindLayoutParsesDigestReferenceWithLayoutMarker(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + require.NoError(t, ensureLayout(filepath.Join("layout", "base"))) + digest := ocidigest.FromBytes([]byte("manifest")) + + baseDir, ref, err := FindLayout("./layout/base/repo/name:tag@" + digest.String()) + require.NoError(t, err) + require.Equal(t, "./layout/base", baseDir) + require.Empty(t, ref.Host) + require.Equal(t, "repo/name", ref.Repository) + require.Equal(t, "tag", ref.Tag) + require.Equal(t, digest, ref.Digest) +} + +func TestFindLayoutParsesDigestOnlyReference(t *testing.T) { + digest := ocidigest.FromBytes([]byte("manifest")) + + baseDir, ref, err := FindLayout("./layout/repo@" + digest.String()) + require.NoError(t, err) + require.Equal(t, "./layout", baseDir) + require.Empty(t, ref.Host) + require.Equal(t, "repo", ref.Repository) + require.Empty(t, ref.Tag) + require.Equal(t, digest, ref.Digest) +} + +func TestFindLayoutReturnsReferenceErrors(t *testing.T) { + _, _, err := FindLayout("./foo/Bad:tag") + require.ErrorContains(t, err, "invalid reference syntax") + + _, _, err = FindLayout("") + require.ErrorContains(t, err, "path must not be empty") +} + +func requireRef(t *testing.T, ref ociref.Reference, repo, tag string) { + t.Helper() + require.Empty(t, ref.Host) + require.Equal(t, repo, ref.Repository) + require.Equal(t, tag, ref.Tag) + require.Empty(t, ref.Digest) +} diff --git a/ocilayout/layout.go b/ocilayout/layout.go new file mode 100644 index 0000000..d19eefb --- /dev/null +++ b/ocilayout/layout.go @@ -0,0 +1,283 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ocilayout provides an [oci.Interface] implementation backed by an +// OCI Image Layout directory. +package ocilayout + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/docker/oci" + "github.com/docker/oci/ociref" +) + +const ( + layoutVersion = "1.0.0" + refNameAnnotation = "org.opencontainers.image.ref.name" +) + +// Options holds configuration for opening a layout registry. +type Options struct { + // DefaultRepo is used when reading layout entries whose + // org.opencontainers.image.ref.name annotation is empty or tag-only. + DefaultRepo string +} + +// Registry is an OCI Image Layout backed implementation of [oci.Interface]. +type Registry struct { + *oci.Funcs + mu sync.Mutex + dir string + opts Options + state *layoutState +} + +var _ oci.Interface = (*Registry)(nil) + +type layoutState struct { + dir string + index oci.IndexOrManifest +} + +type layoutVersionFile struct { + ImageLayoutVersion string `json:"imageLayoutVersion"` +} + +// New opens an OCI Image Layout registry rooted at dir. +// +// Missing layout files are not created by New. Write operations create the +// required layout files and directories lazily. +func New(dir string, opts *Options) (*Registry, error) { + if opts == nil { + opts = &Options{} + } + if dir == "" { + return nil, fmt.Errorf("directory must not be empty") + } + if opts.DefaultRepo != "" && !ociref.IsValidRepository(opts.DefaultRepo) { + return nil, oci.ErrNameInvalid + } + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + r := &Registry{ + dir: abs, + opts: *opts, + } + if _, err := r.loadLayoutLocked(false); err != nil { + return nil, err + } + return r, nil +} + +func (r *Registry) layoutForRepoLocked(repo string, forWrite bool) (*layoutState, error) { + if !ociref.IsValidRepository(repo) { + return nil, oci.ErrNameInvalid + } + return r.loadLayoutLocked(forWrite) +} + +func (r *Registry) loadLayoutLocked(forWrite bool) (*layoutState, error) { + if r.state != nil { + if forWrite { + if err := ensureLayout(r.state.dir); err != nil { + return nil, err + } + } + return r.state, nil + } + if forWrite { + if err := ensureLayout(r.dir); err != nil { + return nil, err + } + } + index, err := loadIndex(r.dir) + if err != nil { + if !forWrite && errors.Is(err, os.ErrNotExist) { + index = emptyIndex() + } else { + return nil, err + } + } + st := &layoutState{ + dir: r.dir, + index: index, + } + r.state = st + return st, nil +} + +func ensureLayout(dir string) error { + if err := os.MkdirAll(filepath.Join(dir, "blobs", "uploads"), 0o700); err != nil { + return err + } + layoutPath := filepath.Join(dir, "oci-layout") + if _, err := os.Stat(layoutPath); errors.Is(err, os.ErrNotExist) { + data, err := json.Marshal(layoutVersionFile{ImageLayoutVersion: layoutVersion}) + if err != nil { + return err + } + if err := os.WriteFile(layoutPath, append(data, '\n'), 0o600); err != nil { + return err + } + } else if err != nil { + return err + } + indexPath := filepath.Join(dir, "index.json") + if _, err := os.Stat(indexPath); errors.Is(err, os.ErrNotExist) { + return saveIndex(dir, emptyIndex()) + } else if err != nil { + return err + } + return validateLayoutFile(dir) +} + +func loadIndex(dir string) (oci.IndexOrManifest, error) { + if err := validateLayoutFile(dir); err != nil { + return oci.IndexOrManifest{}, err + } + blobDir := filepath.Join(dir, "blobs") + if info, err := os.Stat(blobDir); err != nil { + return oci.IndexOrManifest{}, err + } else if !info.IsDir() { + return oci.IndexOrManifest{}, fmt.Errorf("%s is not a directory", blobDir) + } + data, err := os.ReadFile(filepath.Join(dir, "index.json")) + if err != nil { + return oci.IndexOrManifest{}, err + } + var index oci.IndexOrManifest + if err := json.Unmarshal(data, &index); err != nil { + return oci.IndexOrManifest{}, fmt.Errorf("cannot unmarshal index.json: %v", err) + } + if index.MediaType == "" { + index.MediaType = oci.MediaTypeImageIndex + } + if index.SchemaVersion == 0 { + index.SchemaVersion = 2 + } + if index.MediaType != oci.MediaTypeImageIndex && index.MediaType != oci.MediaTypeDockerManifestList { + return oci.IndexOrManifest{}, fmt.Errorf("index.json has unexpected media type %q", index.MediaType) + } + if err := index.Validate(); err != nil { + return oci.IndexOrManifest{}, fmt.Errorf("invalid index.json: %v", err) + } + return index, nil +} + +func validateLayoutFile(dir string) error { + data, err := os.ReadFile(filepath.Join(dir, "oci-layout")) + if err != nil { + return err + } + var layout layoutVersionFile + if err := json.Unmarshal(data, &layout); err != nil { + return fmt.Errorf("cannot unmarshal oci-layout: %v", err) + } + if layout.ImageLayoutVersion != layoutVersion { + return fmt.Errorf("unsupported OCI image layout version %q", layout.ImageLayoutVersion) + } + return nil +} + +func saveIndex(dir string, index oci.IndexOrManifest) error { + if index.SchemaVersion == 0 { + index.SchemaVersion = 2 + } + if index.MediaType == "" { + index.MediaType = oci.MediaTypeImageIndex + } + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + tmp := filepath.Join(dir, "index.json.tmp") + if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(dir, "index.json")) +} + +func emptyIndex() oci.IndexOrManifest { + return oci.IndexOrManifest{ + SchemaVersion: 2, + MediaType: oci.MediaTypeImageIndex, + } +} + +func (r *Registry) refFor(repo string, tag string, digest oci.Digest) string { + if tag == "" { + return repo + "@" + digest.String() + } + return repo + ":" + tag +} + +func (r *Registry) parseRef(ref string) (repo string, tag string, err error) { + if ref == "" { + if r.opts.DefaultRepo == "" { + return "", "", fmt.Errorf("empty ref.name") + } + return r.opts.DefaultRepo, "", nil + } + if ociref.IsValidTag(ref) && !strings.ContainsAny(ref, "/:") { + if r.opts.DefaultRepo != "" { + return r.opts.DefaultRepo, ref, nil + } + return "", "", fmt.Errorf("ambiguous ref.name %q", ref) + } + if parsed, ok := parseFullRef(ref); ok { + return parsed.repo, parsed.tag, nil + } + return "", "", fmt.Errorf("invalid ref.name %q", ref) +} + +type parsedRef struct { + repo string + tag string +} + +func parseFullRef(ref string) (parsedRef, bool) { + name, digest, hasDigest := strings.Cut(ref, "@") + if hasDigest && !ociref.IsValidDigest(digest) { + return parsedRef{}, false + } + if ociref.IsValidRepository(name) { + return parsedRef{repo: name}, true + } + i := strings.LastIndex(name, ":") + if i <= 0 { + return parsedRef{}, false + } + repo, tag := name[:i], name[i+1:] + if !ociref.IsValidRepository(repo) || !ociref.IsValidTag(tag) { + return parsedRef{}, false + } + return parsedRef{repo: repo, tag: tag}, true +} + +func contextErr(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return nil +} diff --git a/ocilayout/layout_test.go b/ocilayout/layout_test.go new file mode 100644 index 0000000..11a3e19 --- /dev/null +++ b/ocilayout/layout_test.go @@ -0,0 +1,307 @@ +package ocilayout + +import ( + "bytes" + "context" + "encoding/json" + "io" + "iter" + "os" + "path/filepath" + "testing" + + "github.com/docker/oci" + "github.com/docker/oci/ocidigest" + "github.com/stretchr/testify/require" +) + +func TestSharedLayoutPushAndRead(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + require.NoFileExists(t, filepath.Join(dir, "oci-layout")) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer := pushTestBlob(ctx, t, reg, "example/app", []byte("layer")) + manifest := testManifest(t, config, layer) + desc, err := reg.PushManifest(ctx, "example/app", manifest, oci.MediaTypeImageManifest, &oci.PushManifestParameters{ + Tags: []string{"latest", "v1"}, + }) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dir, "oci-layout")) + require.FileExists(t, filepath.Join(dir, "index.json")) + require.FileExists(t, filepath.Join(dir, "blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded())) + require.ElementsMatch(t, []string{"latest", "v1"}, all(t, reg.Tags(ctx, "example/app", nil))) + require.Equal(t, []string{"example/app"}, all(t, reg.Repositories(ctx, ""))) + + got, err := reg.ResolveTag(ctx, "example/app", "latest") + require.NoError(t, err) + require.Equal(t, desc.Digest, got.Digest) + + br, err := reg.GetBlob(ctx, "example/app", layer.Digest) + require.NoError(t, err) + data, err := io.ReadAll(br) + require.NoError(t, err) + require.NoError(t, br.Close()) + require.Equal(t, []byte("layer"), data) + + refs := indexRefs(t, filepath.Join(dir, "index.json")) + require.ElementsMatch(t, []string{"example/app:latest", "example/app:v1"}, refs) +} + +func TestSharedLayoutUntaggedManifest(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer := pushTestBlob(ctx, t, reg, "example/app", []byte("layer")) + desc, err := reg.PushManifest(ctx, "example/app", testManifest(t, config, layer), oci.MediaTypeImageManifest, nil) + require.NoError(t, err) + + got, err := reg.ResolveManifest(ctx, "example/app", desc.Digest) + require.NoError(t, err) + require.Equal(t, desc.Digest, got.Digest) + require.Equal(t, []string{"example/app@" + desc.Digest.String()}, indexRefs(t, filepath.Join(dir, "index.json"))) +} + +func TestSharedLayoutMultipleUntaggedManifests(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer1 := pushTestBlob(ctx, t, reg, "example/app", []byte("layer1")) + layer2 := pushTestBlob(ctx, t, reg, "example/app", []byte("layer2")) + desc1, err := reg.PushManifest(ctx, "example/app", testManifest(t, config, layer1), oci.MediaTypeImageManifest, nil) + require.NoError(t, err) + desc2, err := reg.PushManifest(ctx, "example/app", testManifest(t, config, layer2), oci.MediaTypeImageManifest, nil) + require.NoError(t, err) + + got, err := reg.ResolveManifest(ctx, "example/app", desc1.Digest) + require.NoError(t, err) + require.Equal(t, desc1.Digest, got.Digest) + got, err = reg.ResolveManifest(ctx, "example/app", desc2.Digest) + require.NoError(t, err) + require.Equal(t, desc2.Digest, got.Digest) + require.ElementsMatch(t, []string{ + "example/app@" + desc1.Digest.String(), + "example/app@" + desc2.Digest.String(), + }, indexRefs(t, filepath.Join(dir, "index.json"))) + require.Equal(t, []string{"example/app"}, all(t, reg.Repositories(ctx, ""))) +} + +func TestPushManifestAllowsMissingLayerWithURLs(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer := oci.Descriptor{ + MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip", + Digest: ocidigest.FromBytes([]byte("foreign layer")), + Size: int64(len("foreign layer")), + URLs: []string{"https://example.com/foreign-layer"}, + } + _, err = reg.PushManifest(ctx, "example/app", testManifest(t, config, layer), oci.MediaTypeImageManifest, nil) + require.NoError(t, err) +} + +func TestPushManifestRejectsMissingLayerWithoutURLs(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer := oci.Descriptor{ + MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + Digest: ocidigest.FromBytes([]byte("missing layer")), + Size: int64(len("missing layer")), + } + _, err = reg.PushManifest(ctx, "example/app", testManifest(t, config, layer), oci.MediaTypeImageManifest, nil) + require.ErrorIs(t, err, oci.ErrManifestInvalid) + require.ErrorContains(t, err, "blob for layers[0] not found") +} + +func TestDefaultRepoReadsTagOnlyRefs(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{DefaultRepo: "example/app"}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer := pushTestBlob(ctx, t, reg, "example/app", []byte("layer")) + desc, err := reg.PushManifest(ctx, "example/app", testManifest(t, config, layer), oci.MediaTypeImageManifest, &oci.PushManifestParameters{ + Tags: []string{"latest"}, + }) + require.NoError(t, err) + + indexPath := filepath.Join(dir, "index.json") + require.Equal(t, []string{"example/app:latest"}, indexRefs(t, indexPath)) + + index := readIndex(t, indexPath) + index.Manifests[0].Annotations[refNameAnnotation] = "latest" + writeIndex(t, indexPath, index) + + reg, err = New(dir, &Options{DefaultRepo: "example/app"}) + require.NoError(t, err) + got, err := reg.ResolveTag(ctx, "example/app", "latest") + require.NoError(t, err) + require.Equal(t, desc.Digest, got.Digest) +} + +func TestPerRepositoryHelperUsesRepoDirectories(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := NewPerRepository(dir, &PerRepoOptions{}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + layer := pushTestBlob(ctx, t, reg, "example/app", []byte("layer")) + desc, err := reg.PushManifest(ctx, "example/app", testManifest(t, config, layer), oci.MediaTypeImageManifest, &oci.PushManifestParameters{ + Tags: []string{"latest"}, + }) + require.NoError(t, err) + + indexPath := filepath.Join(dir, "example", "app", "index.json") + require.Equal(t, []string{"example/app:latest"}, indexRefs(t, indexPath)) + require.Equal(t, []string{"example/app"}, all(t, reg.Repositories(ctx, ""))) + + index := readIndex(t, indexPath) + index.Manifests[0].Annotations[refNameAnnotation] = "latest" + writeIndex(t, indexPath, index) + + reg, err = NewPerRepository(dir, &PerRepoOptions{}) + require.NoError(t, err) + got, err := reg.ResolveTag(ctx, "example/app", "latest") + require.NoError(t, err) + require.Equal(t, desc.Digest, got.Digest) +} + +func TestSharedLayoutAmbiguousRefErrors(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + require.NoError(t, ensureLayout(dir)) + index := emptyIndex() + index.Manifests = []oci.Descriptor{{ + MediaType: oci.MediaTypeImageManifest, + Digest: ocidigest.FromBytes([]byte("manifest")), + Size: int64(len("manifest")), + Annotations: map[string]string{ + refNameAnnotation: "latest", + }, + }} + require.NoError(t, saveIndex(dir, index)) + + reg, err := New(dir, &Options{}) + require.NoError(t, err) + _, err = oci.All(reg.Repositories(ctx, "")) + require.ErrorContains(t, err, "ambiguous ref.name") +} + +func TestBlobCanBeReadWithoutManifestReachability(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + + config := pushTestBlob(ctx, t, reg, "example/app", []byte("{}")) + reachable := pushTestBlob(ctx, t, reg, "example/app", []byte("reachable")) + unreachable := pushTestBlob(ctx, t, reg, "example/app", []byte("unreachable")) + _, err = reg.PushManifest(ctx, "example/app", testManifest(t, config, reachable), oci.MediaTypeImageManifest, &oci.PushManifestParameters{ + Tags: []string{"latest"}, + }) + require.NoError(t, err) + + br, err := reg.GetBlob(ctx, "example/app", unreachable.Digest) + require.NoError(t, err) + data, err := io.ReadAll(br) + require.NoError(t, err) + require.NoError(t, br.Close()) + require.Equal(t, []byte("unreachable"), data) + require.Equal(t, unreachable.Digest, br.Descriptor().Digest) + require.Equal(t, unreachable.Size, br.Descriptor().Size) +} + +func TestChunkedUploadUsesUploadsDirectory(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + reg, err := New(dir, &Options{}) + require.NoError(t, err) + + w, err := reg.PushBlobChunked(ctx, "example/app", 0) + require.NoError(t, err) + _, err = w.Write([]byte("content")) + require.NoError(t, err) + id := w.ID() + require.FileExists(t, filepath.Join(dir, "blobs", "uploads", id)) + desc, err := w.Commit(ocidigest.FromBytes([]byte("content"))) + require.NoError(t, err) + require.NoFileExists(t, filepath.Join(dir, "blobs", "uploads", id)) + require.FileExists(t, filepath.Join(dir, "blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded())) +} + +func pushTestBlob(ctx context.Context, t *testing.T, reg oci.Interface, repo string, data []byte) oci.Descriptor { + t.Helper() + desc := oci.Descriptor{ + MediaType: "application/octet-stream", + Digest: ocidigest.FromBytes(data), + Size: int64(len(data)), + } + got, err := reg.PushBlob(ctx, repo, desc, bytes.NewReader(data)) + require.NoError(t, err) + return got +} + +func testManifest(t *testing.T, config oci.Descriptor, layer oci.Descriptor) []byte { + t.Helper() + config.MediaType = oci.MediaTypeImageConfig + m := oci.IndexOrManifest{ + SchemaVersion: 2, + MediaType: oci.MediaTypeImageManifest, + Config: &config, + Layers: []oci.Descriptor{layer}, + } + data, err := json.Marshal(m) + require.NoError(t, err) + return data +} + +func indexRefs(t *testing.T, path string) []string { + t.Helper() + index := readIndex(t, path) + refs := make([]string, 0, len(index.Manifests)) + for _, desc := range index.Manifests { + refs = append(refs, desc.Annotations[refNameAnnotation]) + } + return refs +} + +func readIndex(t *testing.T, path string) oci.IndexOrManifest { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var index oci.IndexOrManifest + require.NoError(t, json.Unmarshal(data, &index)) + return index +} + +func writeIndex(t *testing.T, path string, index oci.IndexOrManifest) { + t.Helper() + data, err := json.MarshalIndent(index, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(data, '\n'), 0o644)) +} + +func all[T any](t *testing.T, seq iter.Seq2[T, error]) []T { + t.Helper() + xs, err := oci.All(seq) + require.NoError(t, err) + return xs +} diff --git a/ocilayout/lister.go b/ocilayout/lister.go new file mode 100644 index 0000000..2fa48d6 --- /dev/null +++ b/ocilayout/lister.go @@ -0,0 +1,151 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "context" + "iter" + "slices" + "strings" + + "github.com/docker/oci" +) + +// Repositories returns an iterator over repository names in the layout registry. +func (r *Registry) Repositories(ctx context.Context, startAfter string) iter.Seq2[string, error] { + r.mu.Lock() + defer r.mu.Unlock() + if err := contextErr(ctx); err != nil { + return oci.ErrorSeq[string](err) + } + repos, err := r.repositoriesLocked() + if err != nil { + return oci.ErrorSeq[string](err) + } + repos = slices.DeleteFunc(repos, func(repo string) bool { + return strings.Compare(startAfter, repo) >= 0 + }) + slices.Sort(repos) + return oci.SliceSeq(repos) +} + +// Tags returns an iterator over tags in the named repository. +func (r *Registry) Tags(ctx context.Context, repo string, params *oci.TagsParameters) iter.Seq2[string, error] { + r.mu.Lock() + defer r.mu.Unlock() + if err := contextErr(ctx); err != nil { + return oci.ErrorSeq[string](err) + } + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return oci.ErrorSeq[string](err) + } + refs, err := r.refsForRepo(st, repo) + if err != nil { + return oci.ErrorSeq[string](err) + } + if len(refs) == 0 { + return oci.ErrorSeq[string](oci.ErrNameUnknown) + } + var startAfter string + var limit int + if params != nil { + startAfter = params.StartAfter + limit = params.Limit + } + var tags []string + for _, ref := range refs { + if ref.tag == "" || strings.Compare(startAfter, ref.tag) >= 0 { + continue + } + tags = append(tags, ref.tag) + } + slices.Sort(tags) + tags = slices.Compact(tags) + return oci.LimitIter(oci.SliceSeq(tags), limit) +} + +// Referrers returns descriptors that refer to the given digest. +func (r *Registry) Referrers(ctx context.Context, repo string, digest oci.Digest, params *oci.ReferrersParameters) iter.Seq2[oci.Descriptor, error] { + r.mu.Lock() + defer r.mu.Unlock() + if err := contextErr(ctx); err != nil { + return oci.ErrorSeq[oci.Descriptor](err) + } + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return oci.ErrorSeq[oci.Descriptor](err) + } + manifests, err := r.reachable(st, repo) + if err != nil { + return oci.ErrorSeq[oci.Descriptor](err) + } + var artifactType string + if params != nil { + artifactType = params.ArtifactType + } + var refs []oci.Descriptor + for _, desc := range manifests { + data, err := osReadBlob(st.dir, desc.Digest) + if err != nil { + return oci.ErrorSeq[oci.Descriptor](err) + } + info, err := manifestInfoFromBytes(desc.MediaType, data) + if err != nil { + return oci.ErrorSeq[oci.Descriptor](err) + } + if info.subject != digest { + continue + } + if artifactType != "" && info.artifactType != artifactType { + continue + } + desc.ArtifactType = info.artifactType + desc.Annotations = info.annotations + refs = append(refs, desc) + } + slices.SortFunc(refs, func(a, b oci.Descriptor) int { + return strings.Compare(a.Digest.String(), b.Digest.String()) + }) + return oci.SliceSeq(refs) +} + +func (r *Registry) repositoriesLocked() ([]string, error) { + st, err := r.loadLayoutLocked(false) + if err != nil { + return nil, err + } + seen := make(map[string]bool) + for _, desc := range st.index.Manifests { + ref := "" + if desc.Annotations != nil { + ref = desc.Annotations[refNameAnnotation] + } + repo, _, err := r.parseRef(ref) + if err != nil { + return nil, err + } + seen[repo] = true + } + return mapKeys(seen), nil +} + +func mapKeys(m map[string]bool) []string { + xs := make([]string, 0, len(m)) + for k := range m { + xs = append(xs, k) + } + return xs +} diff --git a/ocilayout/per_repository.go b/ocilayout/per_repository.go new file mode 100644 index 0000000..0be7223 --- /dev/null +++ b/ocilayout/per_repository.go @@ -0,0 +1,299 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "iter" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/docker/oci" + "github.com/docker/oci/ocifilter" + "github.com/docker/oci/ociref" +) + +// NewPerRepository opens an OCI Image Layout registry that stores each +// repository in a separate OCI layout under dir. +func NewPerRepository(dir string, _ *PerRepoOptions) (oci.Interface, error) { + if dir == "" { + return nil, fmt.Errorf("directory must not be empty") + } + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + return &perRepositoryRegistry{ + dir: abs, + layout: make(map[string]*repoLayout), + }, nil +} + +// PerRepoOptions holds configuration for opening a per-repository layout +// registry. +type PerRepoOptions struct{} + +type perRepositoryRegistry struct { + *oci.Funcs + mu sync.Mutex + dir string + layout map[string]*repoLayout +} + +var _ oci.Interface = (*perRepositoryRegistry)(nil) + +type repoLayout struct { + raw oci.Interface + sub oci.Interface +} + +func (r *perRepositoryRegistry) GetBlob(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return nil, err + } + return layout.GetBlob(ctx, ".", digest) +} + +func (r *perRepositoryRegistry) GetBlobRange(ctx context.Context, repo string, digest oci.Digest, offset0, offset1 int64) (oci.BlobReader, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return nil, err + } + return layout.GetBlobRange(ctx, ".", digest, offset0, offset1) +} + +func (r *perRepositoryRegistry) GetManifest(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return nil, err + } + return layout.GetManifest(ctx, ".", digest) +} + +func (r *perRepositoryRegistry) GetTag(ctx context.Context, repo string, tagName string) (oci.BlobReader, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return nil, err + } + return layout.GetTag(ctx, ".", tagName) +} + +func (r *perRepositoryRegistry) ResolveBlob(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.Descriptor{}, err + } + return layout.ResolveBlob(ctx, ".", digest) +} + +func (r *perRepositoryRegistry) ResolveManifest(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.Descriptor{}, err + } + return layout.ResolveManifest(ctx, ".", digest) +} + +func (r *perRepositoryRegistry) ResolveTag(ctx context.Context, repo string, tagName string) (oci.Descriptor, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.Descriptor{}, err + } + return layout.ResolveTag(ctx, ".", tagName) +} + +func (r *perRepositoryRegistry) PushBlob(ctx context.Context, repo string, desc oci.Descriptor, content io.Reader) (oci.Descriptor, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.Descriptor{}, err + } + return layout.PushBlob(ctx, ".", desc, content) +} + +func (r *perRepositoryRegistry) PushBlobChunked(ctx context.Context, repo string, chunkSize int) (oci.BlobWriter, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return nil, err + } + return layout.PushBlobChunked(ctx, ".", chunkSize) +} + +func (r *perRepositoryRegistry) PushBlobChunkedResume(ctx context.Context, repo, id string, offset int64, chunkSize int) (oci.BlobWriter, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return nil, err + } + return layout.PushBlobChunkedResume(ctx, ".", id, offset, chunkSize) +} + +func (r *perRepositoryRegistry) MountBlob(ctx context.Context, fromRepo, toRepo string, digest oci.Digest) (oci.Descriptor, error) { + if fromRepo == toRepo { + layout, err := r.layoutForRepo(fromRepo) + if err != nil { + return oci.Descriptor{}, err + } + return layout.MountBlob(ctx, ".", ".", digest) + } + desc, err := r.ResolveBlob(ctx, fromRepo, digest) + if err != nil { + return oci.Descriptor{}, err + } + br, err := r.GetBlob(ctx, fromRepo, digest) + if err != nil { + return oci.Descriptor{}, err + } + defer br.Close() + return r.PushBlob(ctx, toRepo, desc, br) +} + +func (r *perRepositoryRegistry) PushManifest(ctx context.Context, repo string, data []byte, mediaType string, params *oci.PushManifestParameters) (oci.Descriptor, error) { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.Descriptor{}, err + } + return layout.PushManifest(ctx, ".", data, mediaType, params) +} + +func (r *perRepositoryRegistry) DeleteBlob(ctx context.Context, repo string, digest oci.Digest) error { + layout, err := r.layoutForRepo(repo) + if err != nil { + return err + } + return layout.DeleteBlob(ctx, ".", digest) +} + +func (r *perRepositoryRegistry) DeleteManifest(ctx context.Context, repo string, digest oci.Digest) error { + layout, err := r.layoutForRepo(repo) + if err != nil { + return err + } + return layout.DeleteManifest(ctx, ".", digest) +} + +func (r *perRepositoryRegistry) DeleteTag(ctx context.Context, repo string, tagName string) error { + layout, err := r.layoutForRepo(repo) + if err != nil { + return err + } + return layout.DeleteTag(ctx, ".", tagName) +} + +func (r *perRepositoryRegistry) Repositories(ctx context.Context, startAfter string) iter.Seq2[string, error] { + if err := contextErr(ctx); err != nil { + return oci.ErrorSeq[string](err) + } + repos, err := r.repositories(ctx) + if err != nil { + return oci.ErrorSeq[string](err) + } + repos = slices.DeleteFunc(repos, func(repo string) bool { + return strings.Compare(startAfter, repo) >= 0 + }) + slices.Sort(repos) + return oci.SliceSeq(repos) +} + +func (r *perRepositoryRegistry) Tags(ctx context.Context, repo string, params *oci.TagsParameters) iter.Seq2[string, error] { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.ErrorSeq[string](err) + } + return layout.Tags(ctx, ".", params) +} + +func (r *perRepositoryRegistry) Referrers(ctx context.Context, repo string, digest oci.Digest, params *oci.ReferrersParameters) iter.Seq2[oci.Descriptor, error] { + layout, err := r.layoutForRepo(repo) + if err != nil { + return oci.ErrorSeq[oci.Descriptor](err) + } + return layout.Referrers(ctx, ".", digest, params) +} + +func (r *perRepositoryRegistry) layoutForRepo(repo string) (oci.Interface, error) { + layout, err := r.openRepoLayout(repo) + if err != nil { + return nil, err + } + return layout.sub, nil +} + +func (r *perRepositoryRegistry) openRepoLayout(repo string) (*repoLayout, error) { + if !ociref.IsValidRepository(repo) { + return nil, oci.ErrNameInvalid + } + r.mu.Lock() + defer r.mu.Unlock() + if layout := r.layout[repo]; layout != nil { + return layout, nil + } + opts := Options{DefaultRepo: repo} + raw, err := New(filepath.Join(r.dir, filepath.FromSlash(repo)), &opts) // #nosec G305 -- repo has been validated as an OCI repository name. + if err != nil { + return nil, err + } + layout := &repoLayout{ + raw: raw, + sub: ocifilter.Sub(raw, repo), + } + r.layout[repo] = layout + return layout, nil +} + +func (r *perRepositoryRegistry) repositories(ctx context.Context) ([]string, error) { + seen := make(map[string]bool) + if err := filepath.WalkDir(r.dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Name() != "index.json" { + return nil + } + repoDir := filepath.Dir(path) + rel, err := filepath.Rel(r.dir, repoDir) + if err != nil { + return err + } + repo := filepath.ToSlash(rel) + if !ociref.IsValidRepository(repo) { + return nil + } + layout, err := r.openRepoLayout(repo) + if err != nil { + return err + } + repos, err := oci.All(layout.raw.Repositories(ctx, "")) + if err != nil { + return err + } + for _, repo := range repos { + seen[repo] = true + } + return nil + }); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + return mapKeys(seen), nil +} diff --git a/ocilayout/reader.go b/ocilayout/reader.go new file mode 100644 index 0000000..d71ccd5 --- /dev/null +++ b/ocilayout/reader.go @@ -0,0 +1,146 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "context" + "errors" + "os" + + "github.com/docker/oci" +) + +// GetBlob returns the content of the blob with the given digest. +func (r *Registry) GetBlob(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { + desc, st, err := r.resolveBlob(ctx, repo, digest) + if err != nil { + return nil, err + } + return openBlob(st.dir, desc) +} + +// GetBlobRange returns a range of bytes from the blob with the given digest. +func (r *Registry) GetBlobRange(ctx context.Context, repo string, digest oci.Digest, offset0, offset1 int64) (oci.BlobReader, error) { + desc, st, err := r.resolveBlob(ctx, repo, digest) + if err != nil { + return nil, err + } + return openBlobRange(st.dir, desc, offset0, offset1) +} + +// GetManifest returns the content of the manifest with the given digest. +func (r *Registry) GetManifest(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { + desc, st, err := r.resolveManifest(ctx, repo, digest) + if err != nil { + return nil, err + } + return openBlob(st.dir, desc) +} + +// GetTag returns the content of the manifest with the given tag. +func (r *Registry) GetTag(ctx context.Context, repo string, tagName string) (oci.BlobReader, error) { + desc, err := r.ResolveTag(ctx, repo, tagName) + if err != nil { + return nil, err + } + r.mu.Lock() + st, err := r.layoutForRepoLocked(repo, false) + r.mu.Unlock() + if err != nil { + return nil, err + } + return openBlob(st.dir, desc) +} + +// ResolveBlob returns the descriptor for the blob with the given digest. +func (r *Registry) ResolveBlob(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { + desc, _, err := r.resolveBlob(ctx, repo, digest) + return desc, err +} + +// ResolveManifest returns the descriptor for the manifest with the given digest. +func (r *Registry) ResolveManifest(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { + desc, _, err := r.resolveManifest(ctx, repo, digest) + return desc, err +} + +// ResolveTag returns the descriptor for the manifest with the given tag. +func (r *Registry) ResolveTag(ctx context.Context, repo string, tagName string) (oci.Descriptor, error) { + if err := contextErr(ctx); err != nil { + return oci.Descriptor{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return oci.Descriptor{}, err + } + return r.tagDescriptor(st, repo, tagName) +} + +func (r *Registry) resolveBlob(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, *layoutState, error) { + if err := contextErr(ctx); err != nil { + return oci.Descriptor{}, nil, err + } + r.mu.Lock() + defer r.mu.Unlock() + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return oci.Descriptor{}, nil, err + } + path, err := blobPath(st.dir, digest) + if err != nil { + return oci.Descriptor{}, nil, err + } + info, err := os.Stat(path) // #nosec G703 -- path is derived from a validated OCI digest. + if errors.Is(err, os.ErrNotExist) { + return oci.Descriptor{}, nil, oci.ErrBlobUnknown + } + if err != nil { + return oci.Descriptor{}, nil, err + } + if info.IsDir() { + return oci.Descriptor{}, nil, oci.ErrBlobUnknown + } + desc := oci.Descriptor{ + Digest: digest, + Size: info.Size(), + } + return desc, st, nil +} + +func (r *Registry) resolveManifest(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, *layoutState, error) { + if err := contextErr(ctx); err != nil { + return oci.Descriptor{}, nil, err + } + r.mu.Lock() + defer r.mu.Unlock() + st, err := r.layoutForRepoLocked(repo, false) + if err != nil { + return oci.Descriptor{}, nil, err + } + manifests, err := r.reachable(st, repo) + if err != nil { + return oci.Descriptor{}, nil, err + } + desc, ok := manifests[digest] + if !ok { + return oci.Descriptor{}, nil, oci.ErrManifestUnknown + } + if err := ensureBlobExists(st.dir, digest); err != nil { + return oci.Descriptor{}, nil, err + } + return desc, st, nil +} diff --git a/ocilayout/refs.go b/ocilayout/refs.go new file mode 100644 index 0000000..e283dbc --- /dev/null +++ b/ocilayout/refs.go @@ -0,0 +1,196 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "cmp" + "encoding/json" + "fmt" + "os" + + "github.com/docker/oci" +) + +type refKind int + +const ( + kindSubjectManifest refKind = iota + kindBlob + kindManifest +) + +type descInfo struct { + name string + kind refKind + desc oci.Descriptor +} + +type manifestInfo struct { + descriptors []descInfo + subject oci.Digest + artifactType string + annotations map[string]string +} + +type topRef struct { + desc oci.Descriptor + repo string + tag string +} + +func (r *Registry) refsForRepo(st *layoutState, repo string) ([]topRef, error) { + var refs []topRef + for _, desc := range st.index.Manifests { + ref, ok := desc.Annotations[refNameAnnotation] + if !ok { + ref = "" + } + gotRepo, tag, err := r.parseRef(ref) + if err != nil { + return nil, err + } + if gotRepo == repo { + refs = append(refs, topRef{desc: desc, repo: gotRepo, tag: tag}) + } + } + return refs, nil +} + +func (r *Registry) tagDescriptor(st *layoutState, repo string, tag string) (oci.Descriptor, error) { + refs, err := r.refsForRepo(st, repo) + if err != nil { + return oci.Descriptor{}, err + } + if len(refs) == 0 { + return oci.Descriptor{}, oci.ErrNameUnknown + } + for _, ref := range refs { + if ref.tag == tag { + return ref.desc, nil + } + } + return oci.Descriptor{}, oci.ErrManifestUnknown +} + +func manifestInfoFromBytes(mediaType string, data []byte) (manifestInfo, error) { + switch mediaType { + case oci.MediaTypeImageManifest, oci.MediaTypeDockerManifest, + oci.MediaTypeImageIndex, oci.MediaTypeDockerManifestList: + default: + return manifestInfo{}, nil + } + var m oci.IndexOrManifest + if err := json.Unmarshal(data, &m); err != nil { + return manifestInfo{}, fmt.Errorf("cannot unmarshal manifest: %v", err) + } + if m.MediaType == "" { + m.MediaType = mediaType + } + if err := m.Validate(); err != nil { + return manifestInfo{}, err + } + return indexOrManifestInfo(m), nil +} + +func indexOrManifestInfo(m oci.IndexOrManifest) manifestInfo { + var info manifestInfo + for i, manifest := range m.Manifests { + info.descriptors = append(info.descriptors, descInfo{ + name: fmt.Sprintf("manifests[%d]", i), + kind: kindManifest, + desc: manifest, + }) + } + for i, layer := range m.Layers { + info.descriptors = append(info.descriptors, descInfo{ + name: fmt.Sprintf("layers[%d]", i), + kind: kindBlob, + desc: layer, + }) + } + if m.Config != nil { + info.descriptors = append(info.descriptors, descInfo{ + name: "config", + kind: kindBlob, + desc: *m.Config, + }) + } + if m.Subject != nil { + info.descriptors = append(info.descriptors, descInfo{ + name: "subject", + kind: kindSubjectManifest, + desc: *m.Subject, + }) + info.subject = m.Subject.Digest + } + if m.Config != nil { + info.artifactType = cmp.Or(m.ArtifactType, m.Config.MediaType) + } else { + info.artifactType = m.ArtifactType + } + info.annotations = m.Annotations + return info +} + +func (r *Registry) reachable(st *layoutState, repo string) (map[oci.Digest]oci.Descriptor, error) { + refs, err := r.refsForRepo(st, repo) + if err != nil { + return nil, err + } + if len(refs) == 0 { + return nil, oci.ErrNameUnknown + } + manifests := make(map[oci.Digest]oci.Descriptor) + visiting := make(map[oci.Digest]bool) + for _, ref := range refs { + if err := r.walkManifest(st, ref.desc, manifests, visiting); err != nil { + return nil, err + } + } + return manifests, nil +} + +func (r *Registry) walkManifest(st *layoutState, desc oci.Descriptor, manifests map[oci.Digest]oci.Descriptor, visiting map[oci.Digest]bool) error { + if visiting[desc.Digest] { + return nil + } + visiting[desc.Digest] = true + defer delete(visiting, desc.Digest) + manifests[desc.Digest] = desc + data, err := osReadBlob(st.dir, desc.Digest) + if err != nil { + return nil + } + info, err := manifestInfoFromBytes(desc.MediaType, data) + if err != nil { + return err + } + for _, child := range info.descriptors { + if child.kind == kindManifest { + if err := r.walkManifest(st, child.desc, manifests, visiting); err != nil { + return err + } + } + } + return nil +} + +func osReadBlob(dir string, digest oci.Digest) ([]byte, error) { + path, err := blobPath(dir, digest) + if err != nil { + return nil, err + } + return os.ReadFile(path) +} diff --git a/ocilayout/writer.go b/ocilayout/writer.go new file mode 100644 index 0000000..9b86ccd --- /dev/null +++ b/ocilayout/writer.go @@ -0,0 +1,334 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocilayout + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/docker/oci" + "github.com/docker/oci/ocidigest" + "github.com/docker/oci/ociref" +) + +// PushBlob pushes a blob described by desc to the given repository. +func (r *Registry) PushBlob(ctx context.Context, repo string, desc oci.Descriptor, content io.Reader) (oci.Descriptor, error) { + if err := contextErr(ctx); err != nil { + return oci.Descriptor{}, err + } + r.mu.Lock() + st, err := r.layoutForRepoLocked(repo, true) + r.mu.Unlock() + if err != nil { + return oci.Descriptor{}, err + } + return writeBlob(st.dir, desc, content) +} + +// PushBlobChunked starts a chunked blob upload to the given repository. +func (r *Registry) PushBlobChunked(ctx context.Context, repo string, chunkSize int) (oci.BlobWriter, error) { + return r.PushBlobChunkedResume(ctx, repo, "", 0, chunkSize) +} + +// PushBlobChunkedResume resumes a previous chunked blob upload. +func (r *Registry) PushBlobChunkedResume(ctx context.Context, repo string, id string, offset int64, chunkSize int) (oci.BlobWriter, error) { + if err := contextErr(ctx); err != nil { + return nil, err + } + r.mu.Lock() + st, err := r.layoutForRepoLocked(repo, true) + r.mu.Unlock() + if err != nil { + return nil, err + } + if chunkSize <= 0 { + chunkSize = 8 * 1024 + } + if id == "" { + id = newUploadID() + } + path := filepath.Join(st.dir, "blobs", "uploads", id) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if offset >= 0 && info.Size() != offset { + f.Close() + return nil, fmt.Errorf("invalid upload offset %d; actual offset %d: %w", offset, info.Size(), oci.ErrRangeInvalid) + } + return &blobWriter{ + registry: r, + layoutDir: st.dir, + id: id, + path: path, + f: f, + chunkSize: chunkSize, + size: info.Size(), + }, nil +} + +// MountBlob makes a blob from one repository available in another. +func (r *Registry) MountBlob(ctx context.Context, fromRepo, toRepo string, dig oci.Digest) (oci.Descriptor, error) { + desc, fromLayout, err := r.resolveBlob(ctx, fromRepo, dig) + if err != nil { + return oci.Descriptor{}, err + } + r.mu.Lock() + toLayout, err := r.layoutForRepoLocked(toRepo, true) + r.mu.Unlock() + if err != nil { + return oci.Descriptor{}, err + } + if fromLayout.dir == toLayout.dir { + return desc, nil + } + src, err := blobPath(fromLayout.dir, dig) + if err != nil { + return oci.Descriptor{}, err + } + in, err := os.Open(src) + if err != nil { + return oci.Descriptor{}, err + } + defer in.Close() + return writeBlob(toLayout.dir, desc, in) +} + +// PushManifest pushes a manifest to the named repository, optionally tagging it. +func (r *Registry) PushManifest(ctx context.Context, repo string, data []byte, mediaType string, params *oci.PushManifestParameters) (oci.Descriptor, error) { + if err := contextErr(ctx); err != nil { + return oci.Descriptor{}, err + } + if mediaType == "" { + return oci.Descriptor{}, fmt.Errorf("%w: empty media type", oci.ErrManifestInvalid) + } + var dig oci.Digest + if params != nil && params.Digest != "" { + dig = params.Digest + if err := verifyBytesDigest(data, dig); err != nil { + return oci.Descriptor{}, err + } + } else { + dig = ocidigest.FromBytes(data) + } + desc := oci.Descriptor{ + MediaType: mediaType, + Digest: dig, + Size: int64(len(data)), + } + var tags []string + if params != nil { + tags = params.Tags + } + for _, tag := range tags { + if !ociref.IsValidTag(tag) { + return oci.Descriptor{}, fmt.Errorf("%w: invalid tag %q", oci.ErrNameInvalid, tag) + } + } + + r.mu.Lock() + defer r.mu.Unlock() + st, err := r.layoutForRepoLocked(repo, true) + if err != nil { + return oci.Descriptor{}, err + } + if err := r.checkManifestReferences(st, mediaType, data); err != nil { + return oci.Descriptor{}, fmt.Errorf("%w: %v", oci.ErrManifestInvalid, err) + } + if _, err := writeBlobBytes(st.dir, desc, data); err != nil { + return oci.Descriptor{}, err + } + if len(tags) == 0 { + r.upsertManifestRef(st, repo, "", desc) + } else { + for _, tag := range tags { + r.upsertManifestRef(st, repo, tag, desc) + } + } + if err := saveIndex(st.dir, st.index); err != nil { + return oci.Descriptor{}, err + } + return desc, nil +} + +func (r *Registry) checkManifestReferences(st *layoutState, mediaType string, data []byte) error { + info, err := manifestInfoFromBytes(mediaType, data) + if err != nil { + return err + } + for _, child := range info.descriptors { + switch child.kind { + case kindBlob: + if len(child.desc.URLs) > 0 { + continue + } + if err := ensureBlobExists(st.dir, child.desc.Digest); err != nil { + return fmt.Errorf("blob for %s not found", child.name) + } + case kindManifest: + if err := ensureBlobExists(st.dir, child.desc.Digest); err != nil { + return fmt.Errorf("manifest for %s not found", child.name) + } + case kindSubjectManifest: + } + } + return nil +} + +func (r *Registry) upsertManifestRef(st *layoutState, repo string, tag string, desc oci.Descriptor) { + ref := r.refFor(repo, tag, desc.Digest) + desc.Annotations = cloneMap(desc.Annotations) + desc.Annotations[refNameAnnotation] = ref + for i, existing := range st.index.Manifests { + existingRef, err := r.refMatches(repo, tag, desc.Digest, existing) + if err == nil && existingRef { + st.index.Manifests[i] = desc + return + } + } + st.index.Manifests = append(st.index.Manifests, desc) +} + +func (r *Registry) refMatches(repo string, tag string, digest oci.Digest, desc oci.Descriptor) (bool, error) { + ref := "" + if desc.Annotations != nil { + ref = desc.Annotations[refNameAnnotation] + } + gotRepo, gotTag, err := r.parseRef(ref) + if err != nil { + return false, err + } + if gotRepo != repo || gotTag != tag { + return false, nil + } + if tag == "" && digest != "" && desc.Digest != digest { + return false, nil + } + return true, nil +} + +func verifyBytesDigest(data []byte, dig oci.Digest) error { + if err := dig.Validate(); err != nil { + return fmt.Errorf("%w: %v", oci.ErrDigestInvalid, err) + } + w, err := ocidigest.NewWriter(nil, dig.Algorithm()) + if err != nil { + return err + } + if _, err := w.Write(data); err != nil { + return err + } + got, err := w.Digest() + if err != nil { + return err + } + if got != dig { + return fmt.Errorf("digest mismatch: %w", oci.ErrDigestInvalid) + } + return nil +} + +func cloneMap(m map[string]string) map[string]string { + n := make(map[string]string, len(m)+1) + for k, v := range m { + n[k] = v + } + return n +} + +type blobWriter struct { + registry *Registry + layoutDir string + id string + path string + f *os.File + chunkSize int + size int64 + closed bool +} + +func (w *blobWriter) Write(p []byte) (int, error) { + if w.closed { + return 0, fmt.Errorf("upload closed") + } + n, err := w.f.Write(p) + w.size += int64(n) + return n, err +} + +func (w *blobWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + return w.f.Close() +} + +func (w *blobWriter) Size() int64 { + return w.size +} + +func (w *blobWriter) ChunkSize() int { + return w.chunkSize +} + +func (w *blobWriter) ID() string { + return w.id +} + +func (w *blobWriter) Commit(digest oci.Digest) (oci.Descriptor, error) { + if !w.closed { + if err := w.Close(); err != nil { + return oci.Descriptor{}, err + } + } + f, err := os.Open(w.path) + if err != nil { + return oci.Descriptor{}, err + } + defer f.Close() + desc := oci.Descriptor{ + Digest: digest, + Size: w.size, + MediaType: "application/octet-stream", + } + desc, err = writeBlob(w.layoutDir, desc, f) + if err != nil { + return oci.Descriptor{}, err + } + _ = os.Remove(w.path) + return desc, nil +} + +func (w *blobWriter) Cancel() error { + if !w.closed { + _ = w.Close() + } + if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +}