// Package oci implements an OCI Distribution Specification v1.1 registry. // https://github.com/opencontainers/distribution-spec/blob/main/spec.md // // Storage layout under ociRoot: // // blobs/sha256/ — content-addressable layer/config blobs // uploads/ — temporary files for in-progress chunked uploads package oci import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" ) // Registry manages the on-disk blob store and is used by the HTTP handler. type Registry struct { root string // absolute path to the OCI storage root } // New creates a Registry rooted at ociRoot, creating the directory tree if needed. func New(ociRoot string) (*Registry, error) { for _, sub := range []string{"blobs/sha256", "uploads"} { if err := os.MkdirAll(filepath.Join(ociRoot, sub), 0700); err != nil { return nil, fmt.Errorf("oci: init storage %s: %w", sub, err) } } return &Registry{root: ociRoot}, nil } // Root returns the storage root path. func (r *Registry) Root() string { return r.root } // ─── Blob paths ─────────────────────────────────────────────────────────────── // BlobPath returns the filesystem path for a blob identified by its digest. // digest must be in the form "sha256:". func (r *Registry) BlobPath(digest string) (string, error) { hex, err := digestHex(digest) if err != nil { return "", err } return filepath.Join(r.root, "blobs", "sha256", hex), nil } // UploadPath returns the filesystem path for a chunked upload session. func (r *Registry) UploadPath(uploadID string) string { return filepath.Join(r.root, "uploads", sanitiseID(uploadID)) } // BlobExists reports whether a blob with the given digest exists on disk. func (r *Registry) BlobExists(digest string) bool { p, err := r.BlobPath(digest) if err != nil { return false } _, err = os.Stat(p) return err == nil } // BlobSize returns the size of the blob in bytes, or -1 if it doesn't exist. func (r *Registry) BlobSize(digest string) int64 { p, err := r.BlobPath(digest) if err != nil { return -1 } info, err := os.Stat(p) if err != nil { return -1 } return info.Size() } // ReadBlob opens a blob for streaming. Caller must close the returned file. func (r *Registry) ReadBlob(digest string) (*os.File, error) { p, err := r.BlobPath(digest) if err != nil { return nil, err } return os.Open(p) } // WriteBlob writes src into the blob store, verifies the digest, and returns // the computed digest string ("sha256:") and size. // If a blob with the same digest already exists it is not overwritten. func (r *Registry) WriteBlob(src io.Reader) (digest string, size int64, err error) { tmp, err := os.CreateTemp(filepath.Join(r.root, "uploads"), "blob-*") if err != nil { return "", 0, fmt.Errorf("oci: create tmp blob: %w", err) } tmpPath := tmp.Name() defer func() { tmp.Close() if err != nil { os.Remove(tmpPath) } }() h := sha256.New() mw := io.MultiWriter(tmp, h) size, err = io.Copy(mw, src) if err != nil { return "", 0, fmt.Errorf("oci: write blob: %w", err) } tmp.Close() digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) dest, err2 := r.BlobPath(digest) if err2 != nil { os.Remove(tmpPath) return "", 0, err2 } if _, statErr := os.Stat(dest); statErr == nil { // Already exists — deduplication. os.Remove(tmpPath) return digest, size, nil } if err = os.Rename(tmpPath, dest); err != nil { return "", 0, fmt.Errorf("oci: commit blob: %w", err) } return digest, size, nil } // FinishUpload finalises a chunked upload: reads the temp file, verifies // clientDigest (if non-empty), atomically moves it to the blob store, and // returns the canonical digest and size. func (r *Registry) FinishUpload(uploadID, clientDigest string) (digest string, size int64, err error) { src := r.UploadPath(uploadID) f, err := os.Open(src) if err != nil { return "", 0, fmt.Errorf("oci: open upload: %w", err) } h := sha256.New() size, err = io.Copy(h, f) f.Close() if err != nil { return "", 0, fmt.Errorf("oci: hash upload: %w", err) } digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) if clientDigest != "" && clientDigest != digest { os.Remove(src) return "", 0, &DigestMismatch{Expected: clientDigest, Actual: digest} } dest, err := r.BlobPath(digest) if err != nil { return "", 0, err } if _, statErr := os.Stat(dest); statErr == nil { // Blob already exists — dedup. os.Remove(src) return digest, size, nil } if err = os.Rename(src, dest); err != nil { return "", 0, fmt.Errorf("oci: commit upload: %w", err) } return digest, size, nil } // AppendUpload appends src to an existing upload session file and returns the // new total offset. func (r *Registry) AppendUpload(uploadID string, src io.Reader) (newOffset int64, err error) { path := r.UploadPath(uploadID) f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) if err != nil { return 0, fmt.Errorf("oci: open upload for append: %w", err) } defer f.Close() n, err := io.Copy(f, src) if err != nil { return 0, fmt.Errorf("oci: append upload: %w", err) } info, err := f.Stat() if err != nil { return n, nil } return info.Size(), nil } // UploadOffset returns the number of bytes written to an upload session so far. func (r *Registry) UploadOffset(uploadID string) int64 { info, err := os.Stat(r.UploadPath(uploadID)) if err != nil { return 0 } return info.Size() } // CancelUpload removes the temporary upload file. func (r *Registry) CancelUpload(uploadID string) { os.Remove(r.UploadPath(uploadID)) } // DeleteBlob removes a blob from disk. func (r *Registry) DeleteBlob(digest string) error { p, err := r.BlobPath(digest) if err != nil { return err } return os.Remove(p) } // ─── Manifest helpers ───────────────────────────────────────────────────────── // ManifestDescriptor extracts the digest and size from a raw manifest body. func ManifestDescriptor(body []byte) (digest string, size int64) { h := sha256.Sum256(body) return "sha256:" + hex.EncodeToString(h[:]), int64(len(body)) } // IsDigestRef returns true when ref looks like a digest ("sha256:"). func IsDigestRef(ref string) bool { return strings.HasPrefix(ref, "sha256:") } // ─── OCI error types ───────────────────────────────────────────────────────── // ErrorCode is an OCI Distribution API error code. type ErrorCode string const ( ErrBlobUnknown ErrorCode = "BLOB_UNKNOWN" ErrBlobUploadInvalid ErrorCode = "BLOB_UPLOAD_INVALID" ErrBlobUploadUnknown ErrorCode = "BLOB_UPLOAD_UNKNOWN" ErrDigestInvalid ErrorCode = "DIGEST_INVALID" ErrManifestBlobUnknown ErrorCode = "MANIFEST_BLOB_UNKNOWN" ErrManifestInvalid ErrorCode = "MANIFEST_INVALID" ErrManifestUnknown ErrorCode = "MANIFEST_UNKNOWN" ErrNameInvalid ErrorCode = "NAME_INVALID" ErrNameUnknown ErrorCode = "NAME_UNKNOWN" ErrTagInvalid ErrorCode = "TAG_INVALID" ErrUnauthorized ErrorCode = "UNAUTHORIZED" ErrDenied ErrorCode = "DENIED" ErrUnsupported ErrorCode = "UNSUPPORTED" ) // APIError is a single OCI error entry. type APIError struct { Code ErrorCode `json:"code"` Message string `json:"message"` Detail interface{} `json:"detail,omitempty"` } // ErrorResponse is the top-level OCI error response body. type ErrorResponse struct { Errors []APIError `json:"errors"` } // NewError builds an ErrorResponse JSON body. func NewError(code ErrorCode, msg string) []byte { b, _ := json.Marshal(ErrorResponse{Errors: []APIError{{Code: code, Message: msg}}}) return b } // DigestMismatch is returned when a client-provided digest doesn't match the computed one. type DigestMismatch struct { Expected string Actual string } func (e *DigestMismatch) Error() string { return fmt.Sprintf("digest mismatch: expected %s, got %s", e.Expected, e.Actual) } // ─── path helpers ───────────────────────────────────────────────────────────── // digestHex validates a "sha256:" digest string and returns the hex part. func digestHex(digest string) (string, error) { if !strings.HasPrefix(digest, "sha256:") { return "", fmt.Errorf("oci: only sha256 digests are supported, got %q", digest) } h := strings.TrimPrefix(digest, "sha256:") if len(h) != 64 { return "", fmt.Errorf("oci: invalid sha256 digest length: %d", len(h)) } return h, nil } // sanitiseID returns only the last path component of an upload ID, // preventing any path traversal regardless of encoding. func sanitiseID(id string) string { return filepath.Base(id) } // ParseOCIPath extracts the image name and the operation kind from a path // under /v2/. name may contain slashes (e.g. "alice/myapp"). // // Returns: name, kind, ref where kind is one of: // // "tags" → ref = "" // "manifest" → ref = tag or digest // "blob" → ref = digest // "upload" → ref = uploadID (empty for new upload) // "" → unrecognised path func ParseOCIPath(rawPath string) (name, kind, ref string) { // Strip leading /v2/ p := strings.TrimPrefix(rawPath, "/v2/") if p == "" || p == "/" { return "", "", "" } // Try known suffixes from most to least specific. type suffix struct { needle string kind string } suffixes := []suffix{ {"/blobs/uploads/", "upload"}, {"/blobs/sha256:", "blob"}, {"/blobs/", "blob"}, {"/manifests/", "manifest"}, {"/tags/list", "tags"}, } for _, s := range suffixes { idx := strings.Index(p, s.needle) if idx < 0 { continue } name = p[:idx] rest := p[idx+len(s.needle):] kind = s.kind switch s.kind { case "blob": // ref is digest: re-attach the sha256: prefix if needed if strings.HasSuffix(s.needle, ":") { ref = "sha256:" + rest } else { ref = rest } case "upload": ref = rest // upload UUID or empty for new session default: ref = rest } return name, kind, ref } return "", "", "" } // ValidateName returns an error if the image name is empty or contains // invalid characters. func ValidateName(name string) error { if name == "" { return errors.New("empty image name") } for _, c := range name { if !isNameChar(c) { return fmt.Errorf("invalid character %q in image name", c) } } return nil } func isNameChar(c rune) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_' || c == '/' }