375 lines
11 KiB
Go
375 lines
11 KiB
Go
// 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/<hex64> — content-addressable layer/config blobs
|
|
// uploads/<uuid> — 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), 0755); 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:<hex>".
|
|
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:<hex>") 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, 0644)
|
|
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:<hex>").
|
|
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:<hex>" 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 strips any path separators from an upload ID.
|
|
func sanitiseID(id string) string {
|
|
return strings.NewReplacer("/", "", "\\", "", "..", "").Replace(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 == '/'
|
|
}
|