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
+}