diff --git a/AGENTS.md b/AGENTS.md index f70131d..e7bb8c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,11 +22,12 @@ A production-ready, lightweight OCI registry server featuring: - ✅ Basic auth (end-1: `/v2/`) - ✅ Blob uploads (end-4a/4b: `POST /v2//blobs/uploads/`) - ✅ Manifest uploads (end-7: `PUT /v2//manifests/`) -- ❌ Blob/manifest retrieval (end-2, end-3) -- ❌ Tag listing (end-8a/8b) -- ❌ Deletion endpoints (end-9, end-10) -- ❌ Advanced upload operations (end-5, end-6, end-11) -- ❌ Granular tag-level permissions +- ✅ Blob/manifest retrieval (end-2, end-3) +- ✅ Tag listing (end-8a/8b) +- ✅ Deletion endpoints (end-9, end-10) +- ✅ Chunked upload operations (end-5, end-6) +- ✅ Cross-repo blob mounting (end-11) +- ✅ Granular tag-level permissions - ❌ Administration API ## Architecture @@ -157,18 +158,18 @@ Reference table from `spec.md` - implement in order of priority: | ID | Method | Endpoint | Status | Priority | |--------|----------------|---------------------------------------------------------------|-----------|----------| | end-1 | GET | `/v2/` | ✅ Done | 1 | -| end-2 | GET/HEAD | `/v2//blobs/` | ⚠️ Stub | 2 | -| end-3 | GET/HEAD | `/v2//manifests/` | ⚠️ Stub | 3 | +| end-2 | GET/HEAD | `/v2//blobs/` | ✅ Done | 2 | +| end-3 | GET/HEAD | `/v2//manifests/` | ✅ Done | 3 | | end-4a | POST | `/v2//blobs/uploads/` | ✅ Done | 4 | | end-4b | POST | `/v2//blobs/uploads/?digest=` | ✅ Done | 5 | -| end-5 | PATCH | `/v2//blobs/uploads/` | ⚠️ Stub | 7 | -| end-6 | PUT | `/v2//blobs/uploads/?digest=` | ⚠️ Stub | 8 | +| end-5 | PATCH | `/v2//blobs/uploads/` | ✅ Done | 7 | +| end-6 | PUT | `/v2//blobs/uploads/?digest=` | ✅ Done | 8 | | end-7 | PUT | `/v2//manifests/` | ✅ Done | 6 | -| end-8a | GET | `/v2//tags/list` | ⚠️ Stub | 9 | -| end-8b | GET | `/v2//tags/list?n=&last=` | ⚠️ Stub | 10 | -| end-9 | DELETE | `/v2//manifests/` | ⚠️ Stub | 11 | -| end-10 | DELETE | `/v2//blobs/` | ⚠️ Stub | 12 | -| end-11 | POST | `/v2//blobs/uploads/?mount=&from=` | ⚠️ Stub | 13 | +| end-8a | GET | `/v2//tags/list` | ✅ Done | 9 | +| end-8b | GET | `/v2//tags/list?n=&last=` | ✅ Done | 10 | +| end-9 | DELETE | `/v2//manifests/` | ✅ Done | 11 | +| end-10 | DELETE | `/v2//blobs/` | ✅ Done | 12 | +| end-11 | POST | `/v2//blobs/uploads/?mount=&from=` | ✅ Done | 13 | ### Implementation Notes @@ -202,23 +203,19 @@ Scan `./tmp/manifests/{org}/{repo}/` directory: ## Missing Features (TODO) ### High Priority -1. **Read Operations** - Implement end-2 (blob GET) and end-3 (manifest GET) for pull workflows -2. **Tag Listing** - Implement end-8a/8b for image discovery -3. **Chunked Uploads** - Implement end-5/6 for large blob uploads -4. **Error Handling** - Proper OCI error response format with error codes (see spec.md) - -### Medium Priority -1. **Granular Permissions** - Tag-level access control (not just user authentication) +1. **Error Handling** - Proper OCI error response format with error codes (see spec.md) 2. **Admin API** - REST endpoints to add/remove users, set permissions 3. **CLI Tool** - Command-line interface for administration tasks -4. **Validation** - Manifest schema validation (OCI image manifest, image index) + +### Medium Priority +1. **Validation** - Manifest schema validation (OCI image manifest, image index) +2. **Garbage Collection** - Clean up unreferenced blobs +3. **Metrics/Health** - Prometheus metrics, health check endpoint ### Low Priority -1. **Garbage Collection** - Clean up unreferenced blobs -2. **Metrics/Health** - Prometheus metrics, health check endpoint -3. **TLS Support** - HTTPS configuration -4. **Docker Image** - Dockerfile for GHCR publishing -5. **Referrers API** - Support for artifact references (spec extension) +1. **TLS Support** - HTTPS configuration +2. **Docker Image** - Dockerfile for GHCR publishing +3. **Referrers API** - Support for artifact references (spec extension) ## Common Tasks diff --git a/src/blobs.rs b/src/blobs.rs index 41f3232..8d44672 100644 --- a/src/blobs.rs +++ b/src/blobs.rs @@ -179,9 +179,7 @@ pub(crate) async fn head_blob_by_digest( #[derive(Deserialize)] pub(crate) struct PostBlobUploadQueryParams { digest: Option, - #[allow(dead_code)] mount: Option, - #[allow(dead_code)] from: Option, } @@ -224,6 +222,66 @@ pub(crate) async fn post_blob_upload( } } + // Handle blob mounting (end-11) + if let (Some(mount_digest), Some(from_repo)) = (¶ms.mount, ¶ms.from) { + let clean_digest = mount_digest.strip_prefix("sha256:").unwrap_or(mount_digest); + + // Parse source repository (format: "org/repo") + let from_parts: Vec<&str> = from_repo.split('/').collect(); + if from_parts.len() == 2 { + let source_org = from_parts[0]; + let source_repo = from_parts[1]; + let source_repository = format!("{}/{}", source_org, source_repo); + + // Check if user has pull permission on source repository + if auth::check_permission( + &state, + &headers, + &source_repository, + None, + permissions::Action::Pull, + ) + .await + .is_ok() + { + // Attempt to mount blob + match storage::mount_blob(source_org, source_repo, &org, &repo, clean_digest) { + Ok(()) => { + log::info!( + "Mounted blob {} from {} to {}", + clean_digest, + from_repo, + repository + ); + + let location = format!( + "http://{}/v2/{}/{}/blobs/sha256:{}", + host, org, repo, clean_digest + ); + + return Response::builder() + .status(StatusCode::CREATED) + .header("Location", location) + .header("Docker-Content-Digest", format!("sha256:{}", clean_digest)) + .body(Body::empty()) + .unwrap(); + } + Err(e) => { + log::warn!( + "Failed to mount blob {}: {} - falling back to upload", + clean_digest, + e + ); + // Fall through to regular upload session creation + } + } + } else { + log::warn!("User lacks permission to mount from {}", from_repo); + // Fall through to regular upload + } + } + } + // If digest is provided, handle monolithic upload (end-4b) if let Some(digest_string) = params.digest { let success = write_blob(&org, &repo, &digest_string, Body::from(body)).await; diff --git a/src/storage.rs b/src/storage.rs index 3e07237..fb4407c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -319,3 +319,53 @@ pub(crate) fn delete_blob(org: &str, repo: &str, digest: &str) -> Result<(), std std::fs::remove_file(blob_path) } + +pub(crate) fn mount_blob( + source_org: &str, + source_repo: &str, + target_org: &str, + target_repo: &str, + digest: &str, +) -> Result<(), std::io::Error> { + let sanitized_source_org = sanitize_string(source_org); + let sanitized_source_repo = sanitize_string(source_repo); + let sanitized_target_org = sanitize_string(target_org); + let sanitized_target_repo = sanitize_string(target_repo); + let sanitized_digest = sanitize_string(digest); + + // Check if blob exists in source repository + let source_path = format!( + "./tmp/blobs/{}/{}/{}", + sanitized_source_org, sanitized_source_repo, sanitized_digest + ); + + if !std::path::Path::new(&source_path).exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Source blob not found", + )); + } + + // Create target directory + let target_dir = format!( + "./tmp/blobs/{}/{}", + sanitized_target_org, sanitized_target_repo + ); + std::fs::create_dir_all(&target_dir)?; + + // Create target path + let target_path = format!("{}/{}", target_dir, sanitized_digest); + + // If target already exists, that's fine (already mounted) + if std::path::Path::new(&target_path).exists() { + return Ok(()); + } + + // Try hard link first (most efficient - no data duplication) + if std::fs::hard_link(&source_path, &target_path).is_err() { + // If hard link fails (cross-device), copy the file + std::fs::copy(&source_path, &target_path)?; + } + + Ok(()) +}