added artifacts

This commit is contained in:
2026-05-12 22:34:26 +02:00
parent 822f723ff1
commit 91462500f0
30 changed files with 2769 additions and 4 deletions
+374
View File
@@ -0,0 +1,374 @@
// 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 == '/'
}
+254
View File
@@ -0,0 +1,254 @@
package oci_test
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/forgeo/forgebucket/internal/domain/oci"
)
func TestParseOCIPath(t *testing.T) {
tests := []struct {
path string
wantName string
wantKind string
wantRef string
}{
{"/v2/", "", "", ""},
{"/v2", "", "", ""},
{"/v2/alice/myapp/tags/list", "alice/myapp", "tags", ""},
{"/v2/alice/myapp/manifests/latest", "alice/myapp", "manifest", "latest"},
{"/v2/alice/myapp/manifests/sha256:abc123", "alice/myapp", "manifest", "sha256:abc123"},
{"/v2/alice/myapp/blobs/sha256:def456", "alice/myapp", "blob", "sha256:def456"},
{"/v2/alice/myapp/blobs/uploads/", "alice/myapp", "upload", ""},
{"/v2/alice/myapp/blobs/uploads/uuid123", "alice/myapp", "upload", "uuid123"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, kind, ref := oci.ParseOCIPath(tt.path)
if name != tt.wantName {
t.Errorf("name = %q, want %q", name, tt.wantName)
}
if kind != tt.wantKind {
t.Errorf("kind = %q, want %q", kind, tt.wantKind)
}
if ref != tt.wantRef {
t.Errorf("ref = %q, want %q", ref, tt.wantRef)
}
})
}
}
func TestValidateName(t *testing.T) {
if err := oci.ValidateName("alice/myapp"); err != nil {
t.Errorf("valid name got error: %v", err)
}
if err := oci.ValidateName(""); err == nil {
t.Error("empty name should error")
}
if err := oci.ValidateName("alice/my app"); err == nil {
t.Error("name with spaces should error")
}
}
func TestBlobPath(t *testing.T) {
dir := t.TempDir()
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
p, err := reg.BlobPath("sha256:" + strings.Repeat("a", 64))
if err != nil {
t.Fatal(err)
}
expectedSuffix := filepath.Join("blobs", "sha256", strings.Repeat("a", 64))
if !strings.HasSuffix(p, expectedSuffix) {
t.Errorf("path %q does not end with %q", p, expectedSuffix)
}
if _, err := reg.BlobPath("sha256:bad"); err == nil {
t.Error("expected error for short hex")
}
if _, err := reg.BlobPath("md5:abc"); err == nil {
t.Error("expected error for non-sha256 algorithm")
}
}
func TestWriteAndReadBlob(t *testing.T) {
dir := t.TempDir()
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
content := []byte("hello oci blob")
digest, size, err := reg.WriteBlob(bytes.NewReader(content))
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("digest should start with sha256:, got %s", digest)
}
if size != int64(len(content)) {
t.Errorf("size = %d, want %d", size, len(content))
}
if !reg.BlobExists(digest) {
t.Error("blob should exist after write")
}
// Deduplication test: writing same content again should succeed without error.
d2, s2, err := reg.WriteBlob(bytes.NewReader(content))
if err != nil {
t.Fatalf("WriteBlob duplicate: %v", err)
}
if d2 != digest {
t.Errorf("digest mismatch: %s vs %s", d2, digest)
}
if s2 != size {
t.Errorf("size mismatch: %d vs %d", s2, size)
}
f, err := reg.ReadBlob(digest)
if err != nil {
t.Fatalf("ReadBlob: %v", err)
}
defer f.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(f)
if buf.String() != string(content) {
t.Errorf("content mismatch: got %s", buf.String())
}
}
func TestUploadSession(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
uploadID := "test-upload-001"
// Append content in chunks.
off, err := reg.AppendUpload(uploadID, strings.NewReader("chunk1"))
if err != nil {
t.Fatalf("AppendUpload: %v", err)
}
if off != 6 {
t.Errorf("expected offset 6, got %d", off)
}
off, err = reg.AppendUpload(uploadID, strings.NewReader("-chunk2"))
if err != nil {
t.Fatalf("AppendUpload second: %v", err)
}
if off != 13 {
t.Errorf("expected offset 13 after chunk2, got %d", off)
}
if reg.UploadOffset(uploadID) != 13 {
t.Errorf("UploadOffset = %d, want 13", reg.UploadOffset(uploadID))
}
// Finish upload with digest.
digest, size, err := reg.FinishUpload(uploadID, "")
if err != nil {
t.Fatalf("FinishUpload: %v", err)
}
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("expected sha256 digest, got %s", digest)
}
if size != 13 {
t.Errorf("expected size 13, got %d", size)
}
if !reg.BlobExists(digest) {
t.Error("blob should exist after finish upload")
}
// Verify content.
f, _ := reg.ReadBlob(digest)
buf := new(bytes.Buffer)
buf.ReadFrom(f)
f.Close()
if buf.String() != "chunk1-chunk2" {
t.Errorf("content = %q, want %q", buf.String(), "chunk1-chunk2")
}
}
func TestFinishUploadDigestMismatch(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
uploadID := "mismatch-upload"
reg.AppendUpload(uploadID, strings.NewReader("some data"))
_, _, err := reg.FinishUpload(uploadID, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if err == nil {
t.Fatal("expected digest mismatch error")
}
if !strings.Contains(err.Error(), "digest mismatch") {
t.Errorf("expected 'digest mismatch', got: %v", err)
}
}
func TestManifestDescriptor(t *testing.T) {
body := []byte(`{"schemaVersion":2}`)
digest, size := oci.ManifestDescriptor(body)
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("digest should be sha256, got %s", digest)
}
if size != int64(len(body)) {
t.Errorf("size = %d, want %d", size, len(body))
}
}
func TestIsDigestRef(t *testing.T) {
if !oci.IsDigestRef("sha256:abc") {
t.Error("sha256:abc should be a digest ref")
}
if oci.IsDigestRef("latest") {
t.Error("latest should NOT be a digest ref")
}
}
func TestDeleteBlob(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
content := []byte("delete me")
digest, _, _ := reg.WriteBlob(bytes.NewReader(content))
if !reg.BlobExists(digest) {
t.Fatal("blob should exist after write")
}
if err := reg.DeleteBlob(digest); err != nil {
t.Fatalf("DeleteBlob: %v", err)
}
if reg.BlobExists(digest) {
t.Error("blob should not exist after delete")
}
}
func TestNewCreatesDirectories(t *testing.T) {
dir := filepath.Join(t.TempDir(), "oci-storage")
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
for _, sub := range []string{"blobs/sha256", "uploads"} {
p := filepath.Join(dir, sub)
if _, err := os.Stat(p); os.IsNotExist(err) {
t.Errorf("directory not created: %s", p)
}
}
_ = reg
}