Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ matched the bytes, or `Unpinned` for an open-world key with no pin. Callers must
verified one. A pin mismatch returns `IntegrityViolation` rather than the bytes,
so the gate fails closed.

The pin itself belongs to the backend, which keeps it with the bytes: an NDJSON
sidecar (`.nora-pins.ndjson`) on the local filesystem, and the user-defined
`sha256` object metadata on S3/GCS, written atomically with the object and read
back on GET/HEAD. The wrapper only validates keys and runs the gate; an object
stored without a digest (or written before pins existed) simply has none and
stays open-world.

The curation layer is a second trust boundary for proxy traffic. When mode is
`enforce`, a package must pass all filters (blocklist, allowlist, namespace,
integrity) before reaching storage. When mode is `audit`, blocked packages
Expand Down Expand Up @@ -149,9 +156,9 @@ nora/
│ │ └── mod.rs # Re-exports: docker_routes(), maven_routes(), ...
│ │
│ ├── storage/
│ │ ├── mod.rs # StorageBackend trait + Storage wrapper (validate + pin gate)
│ │ ├── local.rs # Local filesystem implementation
│ │ └── object.rs # Object-store implementation (S3-compatible + GCS)
│ │ ├── mod.rs # StorageBackend trait + Storage wrapper (validate + verify gate)
│ │ ├── local.rs # Local filesystem implementation (pins in an NDJSON sidecar)
│ │ └── object.rs # Object-store implementation, S3-compatible + GCS (pins in object metadata)
│ │
│ ├── auth/ # Authentication (middleware + providers)
│ │ ├── mod.rs # auth_middleware, provider dispatch
Expand All @@ -165,7 +172,7 @@ nora/
│ ├── validation.rs # Input validation: storage keys, package names, null bytes
│ │
│ ├── verified.rs # Compile-time integrity witnesses (GateOutcome typestate)
│ ├── hash_pin_store.rs # SHA-256 pins recorded on put(), verified on get()
│ ├── hash_pin_store.rs # SHA-256 pin sidecar for the local backend (NDJSON)
│ ├── digest_quarantine.rs # First-seen tracking for proxy-fetched digests
│ ├── circuit_breaker.rs # Per-registry circuit breaker for upstream proxy calls
│ ├── proxy_coalesce.rs # Single-flight coalescing on the proxy cache-miss path
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Changelog
## [Unreleased]

### Fixed
- **GC: tag-rooted mark walk kept a tag manifest's children but swept the digest-named copy of the manifest itself**, so pull-by-digest of a tagged image 404'd after the first GC run while pull-by-tag kept working. The walk now marks `manifests/sha256:<sha256(bytes)>.json` for every tag manifest — the digest alias the OCI distribution spec requires to stay pullable. Orphaned digest manifests now also take their `.meta.json` sidecar with them instead of leaking it. (#949)
### Added
- **Hash pins on S3/GCS via object metadata** — the SHA-256 integrity pin is no longer a local-filesystem-only feature. On object-store backends it is written as the user-defined `sha256` object metadata, atomically with the object, and read back on GET/HEAD, so buffered reads verify at rest and raw files get `ETag`, `If-None-Match` (304) and `If-Match` conditional overwrite on every backend. Pins are now a backend concern: the local backend keeps its NDJSON sidecar (same path and format, no migration), the object-store backend keeps object metadata, and the storage wrapper only validates keys and runs the fail-closed verify gate. Objects written before the upgrade carry no metadata and stay open-world until they are rewritten; `nora re-pin` rewrites the object on an object store, since object metadata cannot be changed in place.
- **Raw upload integrity via `Repr-Digest` (RFC 9530)** — a raw `PUT` may declare `Repr-Digest: sha-256=:BASE64:`; NORA verifies the received body against it before committing, so a corrupted or truncated upload is rejected with `400` instead of being pinned. The pin itself is always the server-computed hash; the header only gates the commit. A `Repr-Digest` without a sha-256 entry is rejected rather than silently skipped.

## [1.2.0] - 2026-08-23

Expand Down
2 changes: 1 addition & 1 deletion COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ Helm charts are stored as OCI artifacts via the Docker registry endpoints. `helm
| Health check | Full | `/health` |
| Swagger/OpenAPI | Full | `/api-docs` |
| S3 backend | Full | AWS S3, Ceph RGW. Basic storage works on any S3-compatible; multi-replica write-serialization has a caveat — see note below. |
| GCS backend | Full | Native Google Cloud Storage (`storage.mode = "gcs"`): Workload Identity / service-account JSON / ambient credentials; endpoint override for emulators and Private Google Access. Same single-writer caveat as S3 for rpm/deb publishing (in-process publish lock). Hash-pinning (at-rest integrity verification) is unavailable on ALL object-store backends, not only S3. |
| GCS backend | Full | Native Google Cloud Storage (`storage.mode = "gcs"`): Workload Identity / service-account JSON / ambient credentials; endpoint override for emulators and Private Google Access. Same single-writer caveat as S3 for rpm/deb publishing (in-process publish lock). Hash-pinning (at-rest integrity verification) works on every backend: the pin is the `sha256` object metadata on S3/GCS. |
| Local filesystem backend | Full | Default, content-addressable |
| Activity log | Full | Recent push/pull in dashboard |
| Backup/restore | Full | CLI commands |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ All endpoints require authentication. Anonymous read is opt-in via `anonymous_re
| Cargo | ✅ | ✅ | `crates.io` (sparse index) | hosted + proxy (sparse index) |
| PyPI | ✅ | ✅ | `pypi.org/simple/` | hosted + proxy |
| Go Modules | ✅ | — | `proxy.golang.org` | proxy only (modules immutable, push not in protocol) |
| Raw files | ❌ | ✅ | — (no upstream) | hosted only; conditional `PUT` (ETag/`If-Match` — local backend only; `If-None-Match: *` works on any backend) |
| Raw files | ❌ | ✅ | — (no upstream) | hosted only; conditional `PUT`/`GET` (ETag, `If-Match`, `If-None-Match`) on every backend; upload verification via `Repr-Digest` (RFC 9530) |
| RubyGems | ✅ | ❌ | `rubygems.org` | proxy only — `gem push` not implemented in NORA v1.1.0 |
| Terraform | ✅ | — | `registry.terraform.io` | proxy only; client configuration notes in COMPAT.md |
| Ansible Galaxy | ✅ | ❌ | `galaxy.ansible.com` | proxy only — `ansible-galaxy collection publish` not implemented |
Expand Down
19 changes: 16 additions & 3 deletions nora-registry/src/gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1366,12 +1366,18 @@ mod tests {
Vec::new()
})
}
async fn put(&self, _key: &str, _data: &[u8]) -> crate::storage::Result<()> {
async fn put(&self, _key: &str, _data: &[u8], _sha256: &str) -> crate::storage::Result<()> {
Ok(())
}
async fn get(&self, _key: &str) -> crate::storage::Result<axum::body::Bytes> {
async fn get(
&self,
_key: &str,
) -> crate::storage::Result<(axum::body::Bytes, Option<String>)> {
Err(crate::storage::StorageError::NotFound)
}
async fn pin(&self, _key: &str) -> Option<String> {
None
}
async fn delete(&self, _key: &str) -> crate::storage::Result<()> {
Ok(())
}
Expand All @@ -1388,6 +1394,7 @@ mod tests {
&self,
_key: &str,
_src: &std::path::Path,
_sha256: Option<&str>,
) -> crate::storage::Result<()> {
Ok(())
}
Expand All @@ -1396,11 +1403,17 @@ mod tests {
_key: &str,
) -> crate::storage::Result<(
u64,
Option<String>,
std::pin::Pin<Box<dyn tokio::io::AsyncRead + Send + Unpin>>,
)> {
Err(crate::storage::StorageError::NotFound)
}
async fn copy(&self, _src: &str, _dst: &str) -> crate::storage::Result<()> {
async fn copy(
&self,
_src: &str,
_dst: &str,
_sha256: Option<&str>,
) -> crate::storage::Result<()> {
Err(crate::storage::StorageError::NotFound)
}
}
Expand Down
Loading
Loading