From 86c0d090c37ebaa642c4a5711f10d7ba805771f9 Mon Sep 17 00:00:00 2001 From: Antoine Bernardeau Date: Fri, 11 Sep 2026 09:54:46 +0000 Subject: [PATCH 1/3] registry: add WebDAV access Expose OCI repositories read-only and add writable file directories for build caches, using the registry's existing authentication and scopes. Files are tags on raw-file manifests in the pool, so they dedup, compress and expire under gc like everything else. --- CHANGELOG.md | 12 + vk-registry/src/dav/files.rs | 529 +++++++++++++++ vk-registry/src/dav/mod.rs | 585 ++++++++++++++++ vk-registry/src/dav/repos.rs | 414 +++++++++++ vk-registry/src/lib.rs | 289 +++++++- vk-registry/src/upload.rs | 49 +- vk-registry/tests/dav_e2e.rs | 1246 ++++++++++++++++++++++++++++++++++ 7 files changed, 3081 insertions(+), 43 deletions(-) create mode 100644 vk-registry/src/dav/files.rs create mode 100644 vk-registry/src/dav/mod.rs create mode 100644 vk-registry/src/dav/repos.rs create mode 100644 vk-registry/tests/dav_e2e.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f833fd3..e2accb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to virtkit will be documented in this file. ## [Unreleased] +### Added + +- **`vk-registry` serves its store over WebDAV under `/dav/`.** `/dav/repos/` is a + read-only view of every repository the credential may read — tags and manifests download + as manifests, blobs as their bytes — and `/dav/files/` is a plain-file area any WebDAV + client can write to, scoped per top-level directory (`write:files/sccache`). Point + `SCCACHE_WEBDAV_ENDPOINT` at `https:///dav/files/` and every runner's jobs + share one cache of compiled units over the registry's existing TLS and credentials; a + read-only credential gives a pipeline the hits without letting it write. Objects are + stored in the OCI pool as raw-file manifests under `files//…`, dedup and compress + with everything else, and expire under `gc`'s tag retention like any other tag. + ## [0.72.0] - 2026-09-15 ### Added diff --git a/vk-registry/src/dav/files.rs b/vk-registry/src/dav/files.rs new file mode 100644 index 00000000..7179e80d --- /dev/null +++ b/vk-registry/src/dav/files.rs @@ -0,0 +1,529 @@ +//! Plain files under `/dav/files//`, stored in the OCI pool. A path's directories are a +//! repository under `files/` and its leaf a tag on a single-layer raw-file manifest — the shape +//! `/upload` writes — whose layer is the object's bytes. `PUT` streams to `uploads/`, hashes as +//! it goes, promotes the blob and writes the manifest and tag; `MKCOL` creates an empty +//! repository; `DELETE` drops a tag, or a repository with nothing in it. The whole tree under a +//! top-level directory authorizes as the repository `files/`. There is no store of its own +//! here: the gc's tag retention and blob grace are what expire an entry. + +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; +use bytes::Bytes; +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, LAST_MODIFIED}; +use hyper::{Request, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use super::{ + ALLOWED_FILES, ALLOWED_READ, Depth, Entry, created, href, http_date, method_not_allowed, + multistatus, no_content, not_found, propfind_depth, +}; +use crate::{Authz, Body, STREAM_CHUNK, ServerState, Store, accounts, error_response}; + +/// The repository every `/dav/files/` path lives under. +pub(super) const FILES_REPO: &str = "files"; + +/// Maximum object size, checked both against Content-Length and while streaming. +const MAX_OBJECT: u64 = 4 << 30; + +/// What every object is served as, matching `/v2/` blobs. +const OBJECT_TYPE: &str = "application/octet-stream"; + +/// Minimum idle age before a `GET` refreshes the tag's mtime — the "last used" record the +/// gc's retention keys on — so a cache read is a metadata write at most once an hour. +const TOUCH_AFTER: Duration = Duration::from_secs(3600); + +/// What a `files/` path names once the store has been looked at. +enum Node { + /// A repository under `files/`: a directory. + Dir(String), + /// A tag in a repository under `files/`: `(repository, tag)`, an object. + Object(String, String), +} + +/// The repository a run of path components names: `files/`. +fn repo_name(segs: &[String]) -> String { + let mut name = String::from(FILES_REPO); + for s in segs { + name.push('/'); + name.push_str(s); + } + name +} + +/// The repository and tag an object at `segs` would have: the last component is the tag. +fn object_of(segs: &[String]) -> Option<(String, &str)> { + let (leaf, parents) = segs.split_last()?; + (!parents.is_empty()).then(|| (repo_name(parents), leaf.as_str())) +} + +/// What is at `segs`: a directory, an object, or nothing. A directory is any path under +/// `repos/files/` that is one — a repository, or the parent a nested repository's name +/// created — so the tree reads as the client wrote it. +fn resolve(store: &Store, segs: &[String]) -> Option { + let dir = repo_name(segs); + if store.repo_dir_exists(&dir) { + return Some(Node::Dir(dir)); + } + let (repo, tag) = object_of(segs)?; + store + .tag_target(&repo, tag) + .map(|_| Node::Object(repo, tag.to_string())) +} + +/// Serve one `files/` request; `segs` are the decoded components after `/dav/files/`. +pub(super) async fn route( + state: &ServerState, + authz: &Authz<'_>, + segs: &[String], + method: &str, + req: Request, +) -> Result> { + let mut parts: Vec<&str> = Vec::with_capacity(segs.len() + 1); + parts.push(FILES_REPO); + parts.extend(segs.iter().map(String::as_str)); + let this = href(&parts, false); + let action = match method { + "GET" | "HEAD" | "PROPFIND" => accounts::Action::Read, + "PUT" | "MKCOL" | "DELETE" => accounts::Action::Write, + _ => return Ok(method_not_allowed(&this, ALLOWED_FILES)), + }; + let store = &state.store; + let Some((dir, _)) = segs.split_first() else { + return root(state, authz, method, req).await; + }; + // The path as a repository name is what every component has to pass, the leaf + // included: the OCI name rules, which also keep `tags`, `manifests` and `blobs` out of it. + if !crate::valid_name(&repo_name(segs)) { + return Ok(error_response( + StatusCode::BAD_REQUEST, + "NAME_INVALID", + &this, + )); + } + // The whole tree under a top-level directory authorizes as one repository. + let repo = format!("{FILES_REPO}/{dir}"); + if let Some(resp) = crate::authorize_or_forbidden(authz, action, &repo) { + return Ok(resp); + } + match method { + "PUT" => put(store, segs, &this, req).await, + "MKCOL" => mkcol(store, segs, &href(&parts, true)), + "PROPFIND" => propfind(store, segs, &parts, req).await, + "GET" | "HEAD" => match resolve(store, segs) { + Some(Node::Object(repo, tag)) => { + get(authz, store, &repo, &tag, &this, method == "HEAD") + } + // A collection has no body to serve: listings are `PROPFIND`'s. + _ => Ok(not_found(&this)), + }, + "DELETE" => delete(store, segs, &this), + _ => Ok(method_not_allowed(&this, ALLOWED_FILES)), + } +} + +/// `/dav/files/` itself: a collection of the directories this caller may read. Nothing is +/// written at this level — a directory comes into being through `MKCOL` or `PUT` below it. +async fn root( + state: &ServerState, + authz: &Authz<'_>, + method: &str, + req: Request, +) -> Result> { + let this = href(&[FILES_REPO], true); + match method { + "PROPFIND" => { + let depth = match propfind_depth(req).await { + Ok(d) => d, + Err(resp) => return Ok(*resp), + }; + let store = &state.store; + let mut entries = vec![Entry::collection( + this, + store.repos_path_modified(FILES_REPO), + )]; + if depth == Depth::One { + if !super::may_enumerate(state) { + return Ok(super::enumeration_refused()); + } + for name in store.repo_children(FILES_REPO) { + let repo = format!("{FILES_REPO}/{name}"); + if authz.may_read(&repo) { + entries.push(Entry::collection( + href(&[FILES_REPO, &name], true), + store.repos_path_modified(&repo), + )); + } + } + } + Ok(multistatus(&entries)) + } + "GET" | "HEAD" => Ok(not_found(&this)), + _ => Ok(method_not_allowed(&this, ALLOWED_READ)), + } +} + +/// An object's layer and the tag's mtime: `(layer hex, canonical size, modified)`. `None` +/// for a tag that is absent, or whose manifest is not a raw file — an image pushed over +/// `/v2/` into a `files/` repository is not something this view serves. +fn object_meta(store: &Store, repo: &str, tag: &str) -> Result> { + let Some((manifest_hex, modified)) = store.tag_target(repo, tag) else { + return Ok(None); + }; + let Some(manifest) = store.get_blob(&manifest_hex)? else { + return Ok(None); + }; + Ok(crate::raw_file_layer(&manifest).map(|(hex, size)| (hex, size, modified))) +} + +/// `GET`/`HEAD` an object: the layer blob, through the `/v2/` handler, under this +/// repository's membership. `Range` is ignored, a full 200 being a legal answer to it. +fn get( + authz: &Authz<'_>, + store: &Store, + repo: &str, + tag: &str, + href: &str, + head: bool, +) -> Result> { + let Some((hex, _, modified)) = object_meta(store, repo, tag)? else { + return Ok(not_found(href)); + }; + // A digest is not an entitlement to its bytes: the blob has to be this repository's, + // which the `PUT` that stored it recorded. + if !crate::readable_through(authz, store, repo, &hex) { + return Ok(not_found(href)); + } + // A GET is a use, which is what keeps the tag from the gc's retention; a HEAD is opendal + // checking that something exists. Refreshed only once idle, so a hot entry is not a + // metadata write per hit. + if !head + && SystemTime::now() + .duration_since(modified) + .is_ok_and(|idle| idle > TOUCH_AFTER) + { + store.touch_tag(repo, tag); + } + let mut resp = crate::get_blob(store, &format!("sha256:{hex}"), head, false)?; + if resp.status() == StatusCode::OK { + let headers = resp.headers_mut(); + // Prevent uploaded content from rendering on the `/browse` origin. + headers.insert( + CONTENT_DISPOSITION, + hyper::header::HeaderValue::from_static("attachment"), + ); + if let Ok(v) = hyper::header::HeaderValue::from_str(&http_date(modified)) { + headers.insert(LAST_MODIFIED, v); + } + } + Ok(resp) +} + +/// An object as a `PROPFIND` entry, or `None` when the tag is not a raw file. +fn object_entry(store: &Store, repo: &str, tag: &str, href: String) -> Result> { + Ok(object_meta(store, repo, tag)? + .map(|(_, size, modified)| Entry::file(href, size, OBJECT_TYPE, modified))) +} + +/// `PROPFIND` a directory or an object; at `Depth: 1` a directory lists its members — the +/// repositories below it and its tags. +async fn propfind( + store: &Store, + segs: &[String], + parts: &[&str], + req: Request, +) -> Result> { + let depth = match propfind_depth(req).await { + Ok(d) => d, + Err(resp) => return Ok(*resp), + }; + let child = |name: &str, collection: bool| { + let mut c: Vec<&str> = parts.to_vec(); + c.push(name); + href(&c, collection) + }; + match resolve(store, segs) { + None => Ok(not_found(&href(parts, false))), + Some(Node::Object(repo, tag)) => { + match object_entry(store, &repo, &tag, href(parts, false))? { + Some(e) => Ok(multistatus(&[e])), + None => Ok(not_found(&href(parts, false))), + } + } + Some(Node::Dir(repo)) => { + let mut entries = vec![Entry::collection( + href(parts, true), + store.repos_path_modified(&repo), + )]; + if depth == Depth::One { + for name in store.repo_children(&repo) { + entries.push(Entry::collection( + child(&name, true), + store.repos_path_modified(&format!("{repo}/{name}")), + )); + } + for tag in store.list_tags(&repo) { + if let Some(e) = object_entry(store, &repo, &tag, child(&tag, false))? { + entries.push(e); + } + } + } + Ok(multistatus(&entries)) + } + } +} + +/// Why a streamed `PUT` stopped short. +enum PutError { + /// the body crossed [`MAX_OBJECT`] — a 413, decided while reading + TooLarge, + /// the read or the write failed — a 500 + Failed(anyhow::Error), +} + +/// Stream an object into staging, hashing it on the way, then promote the blob and write the +/// raw-file manifest and tag: 201 for a creation, 204 for a replacement. The body is read +/// through before any answer but a declared length over the cap: a status sent with request +/// bytes unread resets the connection, which `sccache` takes as an unwritable store. +async fn put( + store: &Arc, + segs: &[String], + href: &str, + req: Request, +) -> Result> { + // Reject oversized Content-Length before reading; streaming checks also cover chunked + // bodies. + if req + .headers() + .get(CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n > MAX_OBJECT) + { + return Ok(error_response( + StatusCode::PAYLOAD_TOO_LARGE, + "TOOBIG", + href, + )); + } + // A top-level name is a directory, and so is anything that already is one: neither + // takes an object. Drain the body before returning 405. + let target = match object_of(segs) { + Some(target) if !store.repo_dir_exists(&repo_name(segs)) => Some(target), + _ => None, + }; + let Some((repo, tag)) = target else { + return match drain_into(&mut tokio::io::sink(), req.into_body(), MAX_OBJECT).await { + Ok(_) => Ok(method_not_allowed(href, ALLOWED_FILES)), + Err(PutError::TooLarge) => Ok(error_response( + StatusCode::PAYLOAD_TOO_LARGE, + "TOOBIG", + href, + )), + Err(PutError::Failed(e)) => Err(e), + }; + }; + let existed = store.tag_target(&repo, tag).is_some(); + + // Stream uploads to bound memory use; the digest falls out of the same pass. + let (staging, file) = store.stage_file()?; + let mut file = tokio::fs::File::from_std(file); + let written = drain_into(&mut file, req.into_body(), MAX_OBJECT).await; + let flushed = file + .flush() + .await + .map_err(|e| PutError::Failed(anyhow::Error::from(e).context("writing an object"))); + drop(file); + let (size, hex) = match written.and_then(|w| flushed.map(|()| w)) { + Ok(w) => w, + Err(e) => { + // Nothing else will ever consume it; gc would, but not for a day. + let _ = std::fs::remove_file(&staging); + return match e { + PutError::TooLarge => Ok(error_response( + StatusCode::PAYLOAD_TOO_LARGE, + "TOOBIG", + href, + )), + PutError::Failed(e) => Err(e), + }; + } + }; + + // Off the runtime: staging compresses, and a large object would otherwise block a tokio + // worker for seconds. The store lock is taken inside, after that pass — the same + // sequence as the relay's. + let store = Arc::clone(store); + let tag = tag.to_string(); + tokio::task::spawn_blocking(move || -> Result<()> { + let staged = store.stage_promotion(&hex, &staging)?; + // shared store lock (vs. an exclusive gc) across the promote and the manifest that + // references it; see Store::lock_shared. + let _lock = match store.lock_shared() { + Ok(lock) => lock, + Err(e) => { + staged.discard(); + return Err(e); + } + }; + store.promote_staged(&hex, staged)?; + store.put_raw_file(&repo, &tag, &hex, size, None)?; + Ok(()) + }) + .await + .context("the object's promotion panicked")??; + Ok(if existed { no_content() } else { created() }) +} + +/// Stream `body` to `out`, coalescing small frames and enforcing `cap`, including for chunked +/// bodies; `(bytes written, sha256 hex)` on success. `out` may be a sink when draining a +/// rejected PUT. The generic body allows tests with a small cap. +async fn drain_into( + out: &mut (impl AsyncWrite + Unpin), + mut body: B, + cap: u64, +) -> std::result::Result<(u64, String), PutError> +where + B: hyper::body::Body + Unpin, + B::Error: std::error::Error + Send + Sync + 'static, +{ + let mut buf: Vec = Vec::with_capacity(STREAM_CHUNK); + let mut hasher = Sha256::new(); + let mut total: u64 = 0; + let io = |e: std::io::Error| PutError::Failed(anyhow::Error::from(e).context("writing")); + while let Some(frame) = body.frame().await { + let frame = frame + .map_err(|e| PutError::Failed(anyhow::Error::from(e).context("reading a PUT body")))?; + // Trailers carry no content; a body that has them simply ends after them. + let Ok(data) = frame.into_data() else { + continue; + }; + total = total.saturating_add(data.len() as u64); + if total > cap { + return Err(PutError::TooLarge); + } + hasher.update(&data); + // A frame already worth a write goes straight out rather than through the + // coalescing buffer, which is there for the small ones. + if buf.is_empty() && data.len() >= STREAM_CHUNK { + out.write_all(&data).await.map_err(io)?; + continue; + } + buf.extend_from_slice(&data); + if buf.len() >= STREAM_CHUNK { + out.write_all(&buf).await.map_err(io)?; + buf.clear(); + } + } + if !buf.is_empty() { + out.write_all(&buf).await.map_err(io)?; + } + Ok((total, crate::hex_of(&hasher.finalize()))) +} + +/// Create a directory — an empty repository — and its missing ancestors: 201, or 405 when a +/// directory is already there, or 409 when an object is. +fn mkcol(store: &Store, segs: &[String], href: &str) -> Result> { + match resolve(store, segs) { + Some(Node::Dir(_)) => Ok(method_not_allowed(href, ALLOWED_FILES)), + Some(Node::Object(..)) => Ok(error_response(StatusCode::CONFLICT, "DENIED", href)), + None => { + store.create_repo(&repo_name(segs))?; + Ok(created()) + } + } +} + +/// Delete an object, or a directory with nothing in it (204); 403 for a directory with +/// members; 404 when absent. Recursive deletion is unsupported. An object's bytes stay in +/// the pool until the gc finds them unreferenced. +fn delete(store: &Store, segs: &[String], href: &str) -> Result> { + match resolve(store, segs) { + None => Ok(not_found(href)), + Some(Node::Object(repo, tag)) => { + if store.delete_tag(&repo, &tag)? { + Ok(no_content()) + } else { + Ok(not_found(href)) + } + } + Some(Node::Dir(repo)) => { + if store.remove_empty_repo(&repo)? { + Ok(no_content()) + } else { + Ok(error_response( + StatusCode::FORBIDDEN, + "DENIED", + "a directory with members is not deleted", + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verify the streaming size cap with a small limit and multiple frames, and the digest + /// of what went through. + #[tokio::test] + async fn a_body_over_the_cap_stops_being_written() { + let dir = std::env::temp_dir().join(format!("vk-reg-files-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("staged"); + + // Several frames, so the cap is crossed part way through rather than at the first. + let frames = || { + let chunks: Vec, std::io::Error>> = (0 + ..4) + .map(|_| Ok(hyper::body::Frame::data(Bytes::from(vec![1u8; 100])))) + .collect(); + http_body_util::StreamBody::new(futures::stream::iter(chunks)) + }; + + let mut file = tokio::fs::File::create(&path).await.unwrap(); + assert!(matches!( + drain_into(&mut file, frames(), 250).await, + Err(PutError::TooLarge) + )); + drop(file); + // Refused where the count crossed, so the partial file is bounded by the cap plus + // the frame that crossed it — never the whole body. + let staged = std::fs::metadata(&path).unwrap().len(); + assert!(staged <= 300, "wrote {staged} bytes past a 250-byte cap"); + + // Exactly at the cap is not over it, and the digest is the body's. + let mut file = tokio::fs::File::create(&path).await.unwrap(); + let (n, hex) = drain_into(&mut file, frames(), 400).await.ok().unwrap(); + file.flush().await.unwrap(); + drop(file); + assert_eq!(n, 400); + assert_eq!(std::fs::metadata(&path).unwrap().len(), 400); + assert_eq!(hex, crate::sha256_hex_raw(&vec![1u8; 400])); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn paths_map_onto_repositories_and_tags() { + let segs = |s: &[&str]| s.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(repo_name(&segs(&[])), "files"); + assert_eq!( + repo_name(&segs(&["sccache", "a", "b"])), + "files/sccache/a/b" + ); + assert!( + object_of(&segs(&["sccache"])).is_none(), + "a top-level name is a directory" + ); + let key = segs(&["sccache", "a", "b", "abcdef"]); + let (repo, tag) = object_of(&key).unwrap(); + assert_eq!((repo.as_str(), tag), ("files/sccache/a/b", "abcdef")); + } +} diff --git a/vk-registry/src/dav/mod.rs b/vk-registry/src/dav/mod.rs new file mode 100644 index 00000000..aadf29b5 --- /dev/null +++ b/vk-registry/src/dav/mod.rs @@ -0,0 +1,585 @@ +//! WebDAV under `/dav/`: writable plain files in `files/`, stored in the OCI pool as raw-file +//! manifests, and a read-only OCI view in `repos/`. +//! OCI downloads reuse `/v2/` handlers and authorization; OCI writes remain on `/v2/`. Supports +//! `PROPFIND` (Depth 0/1), `GET`, `HEAD`, `PUT`, `MKCOL`, `DELETE` and `OPTIONS`. PROPFIND +//! bodies are drained without parsing XML. Listings are scope-filtered; root enumeration +//! requires configured credentials. See `DESIGN.md`. + +mod files; +mod repos; + +use std::fmt::Write as _; +use std::time::SystemTime; + +use anyhow::Result; +use bytes::Bytes; +use hyper::body::Incoming; +use hyper::header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS}; +use hyper::{Request, Response, StatusCode}; + +use crate::{Authenticator, Authz, Body, ServerState, body_of, error_response}; + +/// Maximum path depth below `/dav//`. +pub(crate) const MAX_DEPTH: usize = 32; + +/// Maximum decoded component length in bytes. +const MAX_SEGMENT: usize = 255; + +/// Maximum PROPFIND body size; the body is drained and ignored. +const MAX_PROPFIND_BODY: usize = 64 * 1024; + +/// The verbs `files/` answers, for `OPTIONS` and for the `Allow` on a 405. +const ALLOWED_FILES: &str = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL"; + +/// The verbs a read-only resource answers: the root, `repos/`, and the `files/` root. +const ALLOWED_READ: &str = "OPTIONS, GET, HEAD, PROPFIND"; + +/// Serve one `/dav/…` request. The client-auth gate in [`crate::route`] has already run, +/// so a caller here is authenticated; each subtree authorizes per resource. +pub(crate) async fn route( + state: &ServerState, + authz: &Authz<'_>, + req: Request, +) -> Result> { + let path = req.uri().path().to_string(); + // The caller dispatches only `/dav` paths here. + let rest = path.strip_prefix("/dav").unwrap_or(""); + let Some(segments) = parse(rest) else { + return Ok(error_response( + StatusCode::BAD_REQUEST, + "NAME_INVALID", + &path, + )); + }; + let method = req.method().as_str().to_string(); + // Advertise the supported DAV class and verbs; individual resources may allow fewer. + if method == "OPTIONS" { + return Ok(options(ALLOWED_FILES)); + } + match segments.split_first() { + None => root(state, &method, req).await, + Some((area, rest)) if area == "files" => { + files::route(state, authz, rest, &method, req).await + } + Some((area, rest)) if area == "repos" => { + repos::route(state, authz, rest, &method, req).await + } + Some(_) => Ok(not_found(&path)), + } +} + +/// The root collection: `repos/` and `files/`, nothing else. +async fn root(state: &ServerState, method: &str, req: Request) -> Result> { + match method { + "PROPFIND" => { + let depth = match propfind_depth(req).await { + Ok(d) => d, + Err(resp) => return Ok(*resp), + }; + let now = SystemTime::now(); + let mut entries = vec![Entry::collection(href(&[], true), now)]; + if depth == Depth::One { + if !may_enumerate(state) { + return Ok(enumeration_refused()); + } + entries.push(Entry::collection(href(&["repos"], true), now)); + entries.push(Entry::collection( + href(&["files"], true), + state.store.repos_path_modified(files::FILES_REPO), + )); + } + Ok(multistatus(&entries)) + } + // A collection has no body to serve: this server serves no listings on `GET`. + "GET" | "HEAD" => Ok(not_found("/dav/")), + _ => Ok(method_not_allowed("/dav/", ALLOWED_READ)), + } +} + +/// Decode the path after `/dav`, splitting before percent-decoding. Reject decoded separators, +/// control bytes, `.`, `..`, empty or oversized components, and excessive depth. Allow one +/// trailing slash for collections. +pub(crate) fn parse(rest: &str) -> Option> { + // `/dav` and `/dav/` are both the root; `/dav//` is an empty component, refused with + // the rest of them below rather than read as a second spelling of the root. + let rest = rest.strip_prefix('/').unwrap_or(rest); + if rest.is_empty() { + return Some(Vec::new()); + } + let rest = rest.strip_suffix('/').unwrap_or(rest); + if rest.is_empty() { + return None; + } + let mut segments = Vec::new(); + for raw in rest.split('/') { + let seg = crate::percent_decode(raw); + if !valid_segment(&seg) { + return None; + } + segments.push(seg); + } + // The area (`files`, `repos`) sits above the depth bound. + (segments.len() <= MAX_DEPTH + 1).then_some(segments) +} + +/// One decoded path component: non-empty, not a directory traversal, short enough to be a +/// filename, and free of anything that could make it more than one component. +fn valid_segment(seg: &str) -> bool { + !seg.is_empty() + && seg != "." + && seg != ".." + && seg.len() <= MAX_SEGMENT + && !seg.contains(['/', '\\']) + && !seg.chars().any(char::is_control) +} + +/// Build an href from decoded components, percent-encoding each and appending a slash for +/// collections. +pub(crate) fn href(segments: &[&str], collection: bool) -> String { + let mut out = String::from("/dav"); + for seg in segments { + out.push('/'); + out.push_str(&crate::percent_encode(seg)); + } + if collection { + out.push('/'); + } + out +} + +/// Root enumeration requires configured credentials. Accounts-mode listings also filter by +/// scope. +fn may_enumerate(state: &ServerState) -> bool { + match &state.auth { + Authenticator::Accounts { .. } => true, + Authenticator::Shared(auth) => auth.enabled(), + } +} + +fn enumeration_refused() -> Response { + error_response( + StatusCode::FORBIDDEN, + "DENIED", + "listing the store's roots needs a configured credential", + ) +} + +/// What a `PROPFIND` asks about: the resource, the resource and its members, or the whole +/// subtree. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Depth { + Zero, + One, + Infinity, +} + +/// Read Depth and drain the PROPFIND body. Missing Depth defaults to 0; infinity returns 403. +/// The body is ignored and all supported properties are returned. +pub(crate) async fn propfind_depth( + req: Request, +) -> std::result::Result>> { + let depth = match req + .headers() + .get("depth") + .map(|v| v.to_str().map(str::trim).map(str::to_ascii_lowercase)) + { + None => Depth::Zero, + Some(Ok(d)) if d == "0" => Depth::Zero, + Some(Ok(d)) if d == "1" => Depth::One, + Some(Ok(d)) if d == "infinity" => Depth::Infinity, + Some(_) => { + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "UNSUPPORTED", + "Depth must be 0, 1 or infinity", + ))); + } + }; + if depth == Depth::Infinity { + return Err(Box::new(error_response( + StatusCode::FORBIDDEN, + "DENIED", + "Depth: infinity is not served here", + ))); + } + if crate::collect_capped(req, MAX_PROPFIND_BODY).await.is_err() { + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "SIZE_INVALID", + "PROPFIND body is too large", + ))); + } + Ok(depth) +} + +/// Properties of one resource in a 207 response. +pub(crate) struct Entry { + href: String, + kind: Kind, + modified: SystemTime, +} + +enum Kind { + Collection, + File { len: u64, ctype: String }, +} + +impl Entry { + pub(crate) fn collection(href: String, modified: SystemTime) -> Self { + Entry { + href, + kind: Kind::Collection, + modified, + } + } + + pub(crate) fn file(href: String, len: u64, ctype: &str, modified: SystemTime) -> Self { + Entry { + href, + kind: Kind::File { + len, + ctype: ctype.to_string(), + }, + modified, + } + } +} + +/// Build a 207 response with the requested resource first. Each entry has a 200 propstat, +/// resource type and modification time; files also have a length. +pub(crate) fn multistatus(entries: &[Entry]) -> Response { + let mut xml = String::from( + "\n\n", + ); + for e in entries { + // Escape hrefs even though they are already percent-encoded. + let _ = write!( + xml, + " \n {}\n \n \n", + crate::html_escape(&e.href) + ); + match &e.kind { + Kind::File { len, ctype } => { + let _ = write!( + xml, + " \n \ + {len}\n \ + {}\n", + crate::html_escape(ctype) + ); + } + Kind::Collection => { + xml.push_str(" \n"); + } + } + let _ = write!( + xml, + " {}\n \n \ + HTTP/1.1 200 OK\n \n \n", + http_date(e.modified) + ); + } + xml.push_str("\n"); + Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(CONTENT_TYPE, "application/xml; charset=utf-8") + .header(CONTENT_LENGTH, xml.len().to_string()) + .header(X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(body_of(Bytes::from(xml))) + .expect("building a 207") +} + +/// Format an RFC 1123 HTTP date. Times before the epoch render as the epoch. +pub(crate) fn http_date(t: SystemTime) -> String { + const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"]; + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let secs = t + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + let days = secs.div_euclid(86_400); + let sod = secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + // 1970-01-01 was a Thursday, which is why `DAYS` starts there. + let weekday = DAYS[days.rem_euclid(7) as usize]; + // `civil_from_days` yields a month in 1..=12, so this is in range by construction. + let month = MONTHS[(month - 1) as usize]; + format!( + "{weekday}, {day:02} {month} {year} {:02}:{:02}:{:02} GMT", + sod / 3600, + (sod / 60) % 60, + sod % 60, + ) +} + +/// Howard Hinnant's `civil_from_days`: convert days since 1970-01-01 to Gregorian `(year, +/// month, day)`. Month is 1..=12 and day 1..=31. +fn civil_from_days(z: i64) -> (i64, i64, i64) { + // Shift the epoch to 0000-03-01, which puts the leap day at the end of the era. + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // day of era, 0..=146_096 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // 0..=399 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of the March-based year + let mp = (5 * doy + 2) / 153; // March-based month, 0..=11 + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +/// `OPTIONS`: the DAV compliance class opendal looks for, and the verbs served. +fn options(allow: &'static str) -> Response { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1") + .header(ALLOW, allow) + .header(CONTENT_LENGTH, "0") + .body(body_of(Bytes::new())) + .expect("building an OPTIONS response") +} + +fn created() -> Response { + Response::builder() + .status(StatusCode::CREATED) + .header(CONTENT_LENGTH, "0") + .body(body_of(Bytes::new())) + .expect("building a 201") +} + +fn no_content() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(body_of(Bytes::new())) + .expect("building a 204") +} + +fn not_found(href: &str) -> Response { + error_response(StatusCode::NOT_FOUND, "NOT_FOUND", href) +} + +/// Include supported methods in the 405 response. +fn method_not_allowed(href: &str, allow: &'static str) -> Response { + let mut resp = error_response(StatusCode::METHOD_NOT_ALLOWED, "UNSUPPORTED", href); + resp.headers_mut() + .insert(ALLOW, hyper::header::HeaderValue::from_static(allow)); + resp +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// Test valid paths and rejection of unsafe components. + #[test] + fn the_path_parser_refuses_everything_but_a_plain_relative_path() { + let strs = |v: Option>| v; + // the root, both spellings + assert_eq!(strs(parse("")), Some(vec![])); + assert_eq!(strs(parse("/")), Some(vec![])); + // `sccache`'s own two shapes: the startup probe, and a sharded key + assert_eq!( + parse("/files/sccache/.sccache_check"), + Some(vec![ + "files".into(), + "sccache".into(), + ".sccache_check".into() + ]) + ); + assert_eq!( + parse("/files/sccache/a/b/c/abcdef"), + Some(vec![ + "files".into(), + "sccache".into(), + "a".into(), + "b".into(), + "c".into(), + "abcdef".into() + ]) + ); + // a collection, with and without the trailing slash a DAV client may send + assert_eq!( + parse("/files/sccache"), + Some(vec!["files".into(), "sccache".into()]) + ); + assert_eq!( + parse("/files/sccache/"), + Some(vec!["files".into(), "sccache".into()]) + ); + // percent-decoding is per component, so an encoded separator stays in the name + assert_eq!( + parse("/files/x/a%2Db"), + Some(vec!["files".into(), "x".into(), "a-b".into()]) + ); + + for bad in [ + // traversal, encoded and not + "/files/sccache/..", + "/files/sccache/../../etc/passwd", + "/files/sccache/a/../../b", + "/files/sccache/%2E%2E/x", + "/..", + // a decoded separator is a name that would become a path + "/files/sccache/a%2Fb", + "/files/sccache/a%2f%2e%2e%2fb", + "/files/sccache/a%5Cb", + // control bytes, NUL included + "/files/sccache/a%00b", + "/files/sccache/a%0Ab", + // empty components + "/files/sccache//x", + "/files/sccache/x//y", + "/files/sccache//", + "//", + ] { + assert!(parse(bad).is_none(), "accepted {bad:?}"); + } + + // over-long and over-deep + let long = "x".repeat(MAX_SEGMENT + 1); + assert!(parse(&format!("/files/{long}")).is_none()); + assert!(parse(&format!("/files/{}", "x".repeat(MAX_SEGMENT))).is_some()); + let deep = vec!["x"; MAX_DEPTH + 1].join("/"); + assert!(parse(&format!("/files/{deep}")).is_none()); + let deepest = vec!["x"; MAX_DEPTH].join("/"); + assert!(parse(&format!("/files/{deepest}")).is_some()); + } + + /// Hrefs encode decoded components and add a slash for collections. + #[test] + fn hrefs_are_encoded_and_collections_end_in_a_slash() { + assert_eq!(href(&[], true), "/dav/"); + assert_eq!(href(&["files"], true), "/dav/files/"); + assert_eq!(href(&["files", "x", "a b"], false), "/dav/files/x/a%20b"); + assert_eq!( + href(&["repos", "team-a", "app"], true), + "/dav/repos/team-a/app/" + ); + } + + /// Verify response properties, entry order and XML escaping. + #[tokio::test] + async fn a_multistatus_carries_what_the_client_parses() { + use http_body_util::BodyExt as _; + let body = async |resp: Response| { + String::from_utf8( + resp.into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap() + }; + let t = SystemTime::UNIX_EPOCH + Duration::from_secs(784_887_151); + + let resp = multistatus(&[Entry::file( + "/dav/files/sccache/a/b/c/key".into(), + 4096, + "application/octet-stream", + t, + )]); + assert_eq!(resp.status(), StatusCode::MULTI_STATUS); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); + let file = body(resp).await; + assert!( + file.contains("/dav/files/sccache/a/b/c/key"), + "{file}" + ); + assert!(file.contains(""), "{file}"); + assert!( + file.contains("4096"), + "{file}" + ); + assert!( + file.contains("application/octet-stream"), + "{file}" + ); + assert!( + file.contains("Tue, 15 Nov 1994 08:12:31 GMT"), + "{file}" + ); + assert!( + file.contains("HTTP/1.1 200 OK"), + "{file}" + ); + assert!( + file.starts_with(""), + "{file}" + ); + + // A listing: the collection first, then its members, each a response of its own. + let listing = body(multistatus(&[ + Entry::collection("/dav/files/sccache/a/".into(), t), + Entry::collection("/dav/files/sccache/a/b/".into(), t), + Entry::file( + "/dav/files/sccache/a/f".into(), + 1, + "application/octet-stream", + t, + ), + ])) + .await; + assert_eq!(listing.matches("").count(), 3, "{listing}"); + assert_eq!( + listing.matches("").count(), + 3, + "{listing}" + ); + assert_eq!( + listing.matches("").count(), + 1, + "{listing}" + ); + assert_eq!( + listing + .matches("") + .count(), + 2, + "{listing}" + ); + let first = listing.find("/dav/files/sccache/a/").unwrap(); + let second = listing.find("/dav/files/sccache/a/b/").unwrap(); + assert!( + first < second, + "the requested resource comes first: {listing}" + ); + + // Nothing in an href or a type may close an element. + let escaped = body(multistatus(&[Entry::file( + "/dav/b/<&\"'>".into(), + 0, + "a/", + t, + )])) + .await; + assert!(escaped.contains("<&"'>"), "{escaped}"); + assert!(escaped.contains("a/<b>"), "{escaped}"); + assert!(!escaped.contains("<&"), "{escaped}"); + } + + /// Check HTTP dates across leap years, century boundaries and weekdays. + #[test] + fn http_dates_are_rfc_1123() { + for (secs, want) in [ + (0u64, "Thu, 01 Jan 1970 00:00:00 GMT"), + (784_887_151, "Tue, 15 Nov 1994 08:12:31 GMT"), + // 2000-02-29: a leap year a century rule would get wrong + (951_782_400, "Tue, 29 Feb 2000 00:00:00 GMT"), + // 1900 was not one, and 2100 will not be + (4_107_542_400, "Mon, 01 Mar 2100 00:00:00 GMT"), + (2_147_483_647, "Tue, 19 Jan 2038 03:14:07 GMT"), + (86_399, "Thu, 01 Jan 1970 23:59:59 GMT"), + ] { + let got = http_date(SystemTime::UNIX_EPOCH + Duration::from_secs(secs)); + assert_eq!(got, want, "at {secs}"); + } + // Before the epoch renders as the epoch rather than failing a response. + let before = SystemTime::UNIX_EPOCH - Duration::from_secs(1); + assert_eq!(http_date(before), "Thu, 01 Jan 1970 00:00:00 GMT"); + } +} diff --git a/vk-registry/src/dav/repos.rs b/vk-registry/src/dav/repos.rs new file mode 100644 index 00000000..cdf27fdd --- /dev/null +++ b/vk-registry/src/dav/repos.rs @@ -0,0 +1,414 @@ +//! Read-only OCI view under `/dav/repos/`: tags, manifests and blobs for readable repositories. +//! Downloads reuse `/v2/` handlers, including blob decoding and membership checks. Unauthorized +//! repositories return 404. Writes return 405 and must use `/v2/`. + +use std::collections::BTreeSet; + +use anyhow::Result; +use hyper::body::Incoming; +use hyper::{Request, Response}; + +use super::{ + ALLOWED_READ, Depth, Entry, href, method_not_allowed, multistatus, not_found, propfind_depth, +}; +use crate::{Authz, Body, REPO_SUBDIRS, ServerState, Store}; + +/// What every blob is served as, matching `/v2/`. +const BLOB_TYPE: &str = "application/octet-stream"; + +/// A resolved `repos/` path. +enum Node { + Root, + /// A path component above one or more readable repositories that is not itself one: + /// `team-a/` when the caller may read `team-a/app`. + Prefix(String), + Repo(String), + Tags(String), + Manifests(String), + Blobs(String), + Tag(String, String), + Manifest(String, String), + Blob(String, String), +} + +/// Serve one `repos/` request; `segs` are the decoded components after `/dav/repos/`. +pub(super) async fn route( + state: &ServerState, + authz: &Authz<'_>, + segs: &[String], + method: &str, + req: Request, +) -> Result> { + let mut parts: Vec<&str> = Vec::with_capacity(segs.len() + 1); + parts.push("repos"); + parts.extend(segs.iter().map(String::as_str)); + if !matches!(method, "GET" | "HEAD" | "PROPFIND") { + return Ok(method_not_allowed(&href(&parts, false), ALLOWED_READ)); + } + let store = &state.store; + let node = match resolve(store, authz, segs) { + Ok(node) => node, + Err(resp) => return Ok(*resp), + }; + if method == "PROPFIND" { + return propfind(state, authz, &node, &parts, req).await; + } + let head = method == "HEAD"; + let accept_zstd = crate::header_has(&req, hyper::header::ACCEPT_ENCODING, "zstd"); + match &node { + Node::Tag(name, tag) => crate::get_manifest(store, name, tag, head), + Node::Manifest(name, hex) if crate::readable_through(authz, store, name, hex) => { + crate::get_manifest(store, name, &format!("sha256:{hex}"), head) + } + Node::Blob(name, hex) if crate::readable_through(authz, store, name, hex) => { + crate::get_blob(store, &format!("sha256:{hex}"), head, accept_zstd) + } + // A collection has no body to serve; a digest this repository does not hold is + // indistinguishable from an absent one, as on `/v2/`. + _ => Ok(not_found(&href(&parts, false))), + } +} + +/// Resolve a repository path. The first structural component (`tags`, `manifests`, `blobs`) +/// ends the repository name; `valid_name` reserves these components. Check authorization before +/// accessing the repository on disk. +fn resolve(store: &Store, authz: &Authz<'_>, segs: &[String]) -> Result>> { + if segs.is_empty() { + return Ok(Node::Root); + } + let mut parts: Vec<&str> = vec!["repos"]; + parts.extend(segs.iter().map(String::as_str)); + let missing = || Err(Box::new(not_found(&href(&parts, false)))); + let kind_at = segs.iter().position(|s| REPO_SUBDIRS.contains(&s.as_str())); + let name_segs = &segs[..kind_at.unwrap_or(segs.len())]; + if name_segs.is_empty() { + return missing(); + } + let name = name_segs.join("/"); + if !crate::valid_name(&name) { + return Err(Box::new(crate::error_response( + hyper::StatusCode::BAD_REQUEST, + "NAME_INVALID", + &name, + ))); + } + let Some(at) = kind_at else { + if authz.may_read(&name) && store.has_repo(&name) { + return Ok(Node::Repo(name)); + } + let prefix = format!("{name}/"); + if readable_repos(store, authz) + .iter() + .any(|r| r.starts_with(&prefix)) + { + return Ok(Node::Prefix(name)); + } + return missing(); + }; + if !authz.may_read(&name) || !store.has_repo(&name) { + return missing(); + } + let rest = &segs[at + 1..]; + match (segs[at].as_str(), rest) { + ("tags", []) => Ok(Node::Tags(name)), + ("manifests", []) => Ok(Node::Manifests(name)), + ("blobs", []) => Ok(Node::Blobs(name)), + ("tags", [tag]) if crate::valid_tag(tag) => Ok(Node::Tag(name, tag.clone())), + ("manifests", [hex]) if crate::is_blob_hex(hex) => Ok(Node::Manifest(name, hex.clone())), + ("blobs", [hex]) if crate::is_blob_hex(hex) => Ok(Node::Blob(name, hex.clone())), + _ => missing(), + } +} + +/// Every repository this caller may read, sorted. One walk of `repos/`, O(repositories); +/// computed only for the listings and the prefix check, never on a member's own path. +fn readable_repos(store: &Store, authz: &Authz<'_>) -> Vec { + store + .all_repo_names() + .into_iter() + .filter(|r| authz.may_read(r)) + .collect() +} + +/// The next path component of every readable repository below `prefix` (`""` for the +/// root), deduplicated: what a listing of that level shows. +fn children_under(store: &Store, authz: &Authz<'_>, prefix: &str) -> BTreeSet { + readable_repos(store, authz) + .into_iter() + .filter_map(|r| { + let rest = if prefix.is_empty() { + r + } else { + r.strip_prefix(prefix)?.strip_prefix('/')?.to_string() + }; + Some(rest.split('/').next()?.to_string()) + }) + .collect() +} + +/// `PROPFIND` a node: the resource, and at `Depth: 1` its members. +async fn propfind( + state: &ServerState, + authz: &Authz<'_>, + node: &Node, + parts: &[&str], + req: Request, +) -> Result> { + let depth = match propfind_depth(req).await { + Ok(d) => d, + Err(resp) => return Ok(*resp), + }; + let store = &state.store; + let list = depth == Depth::One; + let child = |name: &str, collection: bool| { + let mut c: Vec<&str> = parts.to_vec(); + c.push(name); + href(&c, collection) + }; + // Repository enumeration requires configured credentials. + let enumerate = list && super::may_enumerate(state); + let refused_enumeration = list && !super::may_enumerate(state); + let mut entries = Vec::new(); + match node { + Node::Root | Node::Prefix(_) => { + if refused_enumeration { + return Ok(super::enumeration_refused()); + } + let prefix = match node { + Node::Prefix(p) => p.as_str(), + _ => "", + }; + entries.push(Entry::collection( + href(parts, true), + store.repos_path_modified(prefix), + )); + if enumerate { + for c in children_under(store, authz, prefix) { + let rel = if prefix.is_empty() { + c.clone() + } else { + format!("{prefix}/{c}") + }; + entries.push(Entry::collection( + child(&c, true), + store.repos_path_modified(&rel), + )); + } + } + } + Node::Repo(name) => { + entries.push(Entry::collection( + href(parts, true), + store.repos_path_modified(name), + )); + if list { + for kind in REPO_SUBDIRS { + entries.push(Entry::collection( + child(kind, true), + store.repos_path_modified(&format!("{name}/{kind}")), + )); + } + // Apply the root enumeration rule to nested repositories too. + if enumerate { + for c in children_under(store, authz, name) { + entries.push(Entry::collection( + child(&c, true), + store.repos_path_modified(&format!("{name}/{c}")), + )); + } + } + } + } + Node::Tags(name) => { + entries.push(Entry::collection( + href(parts, true), + store.repos_path_modified(&format!("{name}/tags")), + )); + if list { + for tag in store.list_tags(name) { + if let Some(e) = tag_entry(store, name, &tag, child(&tag, false)) { + entries.push(e); + } + } + } + } + Node::Manifests(name) => { + entries.push(Entry::collection( + href(parts, true), + store.repos_path_modified(&format!("{name}/manifests")), + )); + if list { + for hex in store.repo_member_hexes(name, "manifests") { + if let Some(e) = manifest_entry(store, name, &hex, child(&hex, false)) { + entries.push(e); + } + } + } + } + Node::Blobs(name) => { + entries.push(Entry::collection( + href(parts, true), + store.repos_path_modified(&format!("{name}/blobs")), + )); + if list { + for hex in store.repo_member_hexes(name, "blobs") { + if let Some(e) = blob_entry(store, &hex, child(&hex, false)) { + entries.push(e); + } + } + } + } + Node::Tag(name, tag) => match tag_entry(store, name, tag, href(parts, false)) { + Some(e) => entries.push(e), + None => return Ok(not_found(&href(parts, false))), + }, + Node::Manifest(name, hex) => { + match crate::readable_through(authz, store, name, hex) + .then(|| manifest_entry(store, name, hex, href(parts, false))) + .flatten() + { + Some(e) => entries.push(e), + None => return Ok(not_found(&href(parts, false))), + } + } + Node::Blob(name, hex) => { + match crate::readable_through(authz, store, name, hex) + .then(|| blob_entry(store, hex, href(parts, false))) + .flatten() + { + Some(e) => entries.push(e), + None => return Ok(not_found(&href(parts, false))), + } + } + } + Ok(multistatus(&entries)) +} + +/// A tag as a file: the manifest it resolves to, sized and typed, dated by the tag. +fn tag_entry(store: &Store, name: &str, tag: &str, href: String) -> Option { + let (hex, modified) = store.tag_target(name, tag)?; + let (len, ctype, _) = store.manifest_meta(name, &hex)?; + Some(Entry::file(href, len, &ctype, modified)) +} + +/// A manifest as a file, dated by its membership record. +fn manifest_entry(store: &Store, name: &str, hex: &str, href: String) -> Option { + let (len, ctype, modified) = store.manifest_meta(name, hex)?; + Some(Entry::file(href, len, &ctype, modified)) +} + +/// Blob entry with canonical length, read from the frame header for zstd storage. +fn blob_entry(store: &Store, hex: &str, href: String) -> Option { + let (len, modified) = store.blob_meta(hex)?; + Some(Entry::file(href, len, BLOB_TYPE, modified)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::SystemTime; + + fn store() -> (std::path::PathBuf, Store) { + let dir = std::env::temp_dir().join(format!( + "vk-reg-dav-repos-{}-{:?}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + (dir.clone(), Store::new(dir).unwrap()) + } + + fn segs(path: &str) -> Vec { + path.split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + } + + /// Test repository paths, structural components and invalid members. + #[test] + fn paths_resolve_to_the_view_and_nowhere_else() { + let (dir, store) = store(); + let authz = Authz::NoScopes; + store + .put_manifest( + "team-a/app", + "v1", + "application/vnd.oci.image.manifest.v1+json", + b"{}", + ) + .unwrap(); + let hex = "0".repeat(64); + let r = |p: &str| resolve(&store, &authz, &segs(p)); + + assert!(matches!(r(""), Ok(Node::Root))); + assert!(matches!(r("team-a"), Ok(Node::Prefix(p)) if p == "team-a")); + assert!(matches!(r("team-a/app"), Ok(Node::Repo(n)) if n == "team-a/app")); + assert!(matches!(r("team-a/app/tags"), Ok(Node::Tags(_)))); + assert!(matches!(r("team-a/app/manifests"), Ok(Node::Manifests(_)))); + assert!(matches!(r("team-a/app/blobs"), Ok(Node::Blobs(_)))); + assert!(matches!(r("team-a/app/tags/v1"), Ok(Node::Tag(_, t)) if t == "v1")); + assert!(matches!( + r(&format!("team-a/app/manifests/{hex}")), + Ok(Node::Manifest(_, h)) if h == hex + )); + assert!(matches!( + r(&format!("team-a/app/blobs/{hex}")), + Ok(Node::Blob(_, h)) if h == hex + )); + + let gone = |p: &str| { + let resp = r(p).err().expect(p); + assert_eq!(resp.status(), 404, "{p}"); + }; + // a repository that is not there, and a prefix nothing readable is under + gone("team-b"); + gone("team-a/other"); + gone("team-a/other/tags"); + // a structural name with no repository in front of it + gone("tags"); + gone("blobs/abc"); + // too deep, and members that are not the shape they must be + gone("team-a/app/tags/v1/x"); + gone("team-a/app/blobs/notahex"); + gone("team-a/app/blobs/sha256:abc"); + gone("team-a/app/manifests/ABC"); + // a bad name is a bad request, not a 404 + assert_eq!(r("bad name/app").err().unwrap().status(), 400); + let _ = std::fs::remove_dir_all(&dir); + } + + /// List each readable child component once. + #[test] + fn a_level_lists_the_next_component_of_each_readable_name() { + let (dir, store) = store(); + let authz = Authz::NoScopes; + for repo in ["team-a/app", "team-a/lib", "team-a", "solo"] { + store + .put_manifest( + repo, + "v1", + "application/vnd.oci.image.manifest.v1+json", + b"{}", + ) + .unwrap(); + } + let names = |prefix: &str| { + children_under(&store, &authz, prefix) + .into_iter() + .collect::>() + }; + assert_eq!(names(""), vec!["solo", "team-a"]); + assert_eq!(names("team-a"), vec!["app", "lib"]); + assert!(names("solo").is_empty()); + // `team-a` is both a repository and a prefix, and resolves as the repository + assert!(matches!( + resolve(&store, &authz, &segs("team-a")), + Ok(Node::Repo(_)) + )); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/vk-registry/src/lib.rs b/vk-registry/src/lib.rs index 6287eee7..af79b41e 100644 --- a/vk-registry/src/lib.rs +++ b/vk-registry/src/lib.rs @@ -49,6 +49,7 @@ pub(crate) mod browse; pub(crate) mod captions; pub mod client; pub mod config; +pub(crate) mod dav; pub(crate) mod forms; pub(crate) mod html; pub(crate) mod keys; @@ -431,6 +432,252 @@ impl Store { self.root.join("repos").join(name).join("blobs").join(hex) } + /// A private staging file under `uploads/` for a body whose digest is only known once it + /// has been read — the WebDAV `PUT`. [`Store::stage_promotion`] and + /// [`Store::promote_staged`] install it; one a crash leaves behind is swept with the + /// idle uploads by [`Store::gc`]. + pub(crate) fn stage_file(&self) -> Result<(PathBuf, std::fs::File)> { + let dir = self.uploads_dir(); + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + let path = self.upload_path(&format!( + "{}-{}-{}", + std::process::id(), + self.next_upload.fetch_add(1, Ordering::Relaxed), + accounts::random_token(16), + )); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = opts + .open(&path) + .with_context(|| format!("creating {}", path.display()))?; + Ok((path, file)) + } + + /// The repositories one level below `name` (`""` for the top level), sorted: the + /// subdirectories of `repos/` that are not its own layout directories. By + /// `lstat`, so a symlink is neither followed nor shown. + pub(crate) fn repo_children(&self, name: &str) -> Vec { + if !name.is_empty() && !valid_name(name) { + return Vec::new(); + } + let mut out: Vec = std::fs::read_dir(self.root.join("repos").join(name)) + .ok() + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| !REPO_SUBDIRS.contains(&n.as_str()) && valid_name(n)) + .collect(); + out.sort(); + out + } + + /// Whether `repos/` is a directory: a repository, or a path component above one. + /// What the WebDAV `files/` tree calls a directory, since a nested repository's name + /// brings its parents into being as directories and a client expects to find them. + pub(crate) fn repo_dir_exists(&self, name: &str) -> bool { + valid_name(name) + && std::fs::symlink_metadata(self.root.join("repos").join(name)) + .is_ok_and(|m| m.is_dir()) + } + + /// Bring `name` into being empty: a `tags/` with nothing in it, which is what makes + /// [`Store::has_repo`] answer — for a WebDAV `MKCOL`, where a directory exists before + /// anything is stored under it. Missing ancestors are created with it. + pub(crate) fn create_repo(&self, name: &str) -> Result<()> { + if !valid_name(name) { + bail!("invalid repository name {name}"); + } + let tags = self.root.join("repos").join(name).join("tags"); + std::fs::create_dir_all(&tags).with_context(|| format!("creating {}", tags.display())) + } + + /// Remove a repository that holds no tag and no nested repository. `Ok(false)` when it + /// holds either. What else its directory may still hold — manifest sidecars and + /// membership markers — records content no tag reaches any more, and goes with it. + pub(crate) fn remove_empty_repo(&self, name: &str) -> Result { + if !valid_name(name) { + bail!("invalid repository name {name}"); + } + if !self.repo_children(name).is_empty() { + return Ok(false); + } + let dir = self.root.join("repos").join(name); + // `remove_dir` refuses a directory with entries, so a tag written between the check + // and the removal keeps the repository rather than being lost with it. + match std::fs::remove_dir(dir.join("tags")) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => return Ok(false), + Err(e) => return Err(e).with_context(|| format!("removing {}", dir.display())), + } + std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?; + Ok(true) + } + + /// Drop `name:tag`: the reference goes, the manifest and the blobs it names stay for + /// [`Store::gc`] to sweep once idle. `Ok(false)` when there was no such tag. + pub(crate) fn delete_tag(&self, name: &str, tag: &str) -> Result { + if !valid_name(name) || !valid_tag(tag) { + return Ok(false); + } + let path = self.tag_path(name, tag); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), + } + } + + /// Bump a tag's mtime — the "last used" record [`Store::gc`]'s retention keys on — for a + /// use that did not go through [`Store::get_manifest`]. + pub(crate) fn touch_tag(&self, name: &str, tag: &str) { + if valid_name(name) && valid_tag(tag) { + touch(&self.tag_path(name, tag)); + } + } + + /// Tie a stored blob to `name:tag` as a raw file: the shared empty config, the two + /// membership records, and the single-layer manifest — the same writes a `/v2/` push + /// makes. `size` is the blob's canonical length; `title` is kept as the OCI title + /// annotation when there is one. Under the caller's shared lock, like every + /// check→reference sequence. Returns the manifest digest. + pub(crate) fn put_raw_file( + &self, + name: &str, + tag: &str, + layer_hex: &str, + size: u64, + title: Option<&str>, + ) -> Result { + let config_digest = self.put_blob(RAW_FILE_EMPTY_CONFIG)?; + // These bytes arrived through this server, for this repository, so they are readable + // through it. `put_manifest` records only the manifest itself — a reference is not + // evidence that the referrer holds the content — so the two blobs are recorded here, + // where we do hold them. + self.record_blob(name, config_digest.trim_start_matches("sha256:"))?; + self.record_blob(name, layer_hex)?; + let mut layer = serde_json::json!({ + "mediaType": RAW_FILE_MEDIA_TYPE, + "digest": format!("sha256:{layer_hex}"), + "size": size, + }); + if let Some(title) = title.filter(|t| !t.is_empty()) { + layer["annotations"] = serde_json::json!({ + "org.opencontainers.image.title": title, + }); + } + let manifest = serde_json::json!({ + "schemaVersion": 2, + "mediaType": DEFAULT_MANIFEST_TYPE, + "config": { + "mediaType": RAW_FILE_CONFIG_MEDIA_TYPE, + "digest": config_digest, + "size": RAW_FILE_EMPTY_CONFIG.len(), + }, + "layers": [layer], + }); + self.put_manifest( + name, + tag, + DEFAULT_MANIFEST_TYPE, + serde_json::to_vec(&manifest)?.as_slice(), + ) + } + + /// Check for a valid repository with at least one repository subdirectory, without walking + /// its contents. + pub(crate) fn has_repo(&self, name: &str) -> bool { + valid_name(name) + && REPO_SUBDIRS + .iter() + .any(|k| self.root.join("repos").join(name).join(k).is_dir()) + } + + /// Collection mtime for `repos/`, falling back to the epoch. Empty `rel` refers to + /// `repos/`. + pub(crate) fn repos_path_modified(&self, rel: &str) -> SystemTime { + std::fs::symlink_metadata(self.root.join("repos").join(rel)) + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + } + + /// Return the tag's manifest hex and mtime without refreshing retention. Listings must not + /// keep tags alive. + pub(crate) fn tag_target(&self, name: &str, tag: &str) -> Option<(String, SystemTime)> { + if !valid_name(name) || !valid_tag(tag) { + return None; + } + let path = self.tag_path(name, tag); + let modified = std::fs::symlink_metadata(&path).ok()?.modified().ok()?; + let digest = std::fs::read_to_string(&path).ok()?; + let hex = digest.trim().trim_start_matches("sha256:").to_string(); + is_blob_hex(&hex).then_some((hex, modified)) + } + + /// Manifest length, media type and membership mtime, using the same media-type fallbacks as + /// [`Store::get_manifest`]. + pub(crate) fn manifest_meta(&self, name: &str, hex: &str) -> Option<(u64, String, SystemTime)> { + if !valid_name(name) || !is_blob_hex(hex) { + return None; + } + let blob = self.blob_path(hex); + let len = std::fs::metadata(&blob).ok()?.len(); + let sidecar = self.manifest_type_path(name, hex); + let modified = std::fs::symlink_metadata(&sidecar) + .or_else(|_| std::fs::metadata(&blob)) + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + let ctype = std::fs::read_to_string(&sidecar) + .ok() + .map(|s| s.trim().to_string()) + .or_else(|| { + std::fs::read(&blob) + .ok() + .and_then(|d| declared_media_type(&d)) + }) + .map(|t| manifest_media_type(&t).to_string()) + .unwrap_or_else(|| DEFAULT_MANIFEST_TYPE.to_string()); + Some((len, ctype, modified)) + } + + /// A blob's canonical length and mtime: one `open`, and for a zstd-stored blob a read + /// of the frame header — the same length `Content-Length` reports on a `GET`. + pub(crate) fn blob_meta(&self, hex: &str) -> Option<(u64, SystemTime)> { + let (path, is_zstd) = self.find_blob(hex)?; + let mut file = std::fs::File::open(&path).ok()?; + let meta = file.metadata().ok()?; + let modified = meta.modified().ok()?; + let len = if is_zstd { + zstd_canonical_len(&mut file).ok()? + } else { + meta.len() + }; + Some((len, modified)) + } + + /// Sorted valid digest hexes under `repos//` for DAV listings. + pub(crate) fn repo_member_hexes(&self, name: &str, kind: &str) -> Vec { + if !valid_name(name) || !REPO_SUBDIRS.contains(&kind) { + return Vec::new(); + } + let mut out: Vec = std::fs::read_dir(self.root.join("repos").join(name).join(kind)) + .ok() + .into_iter() + .flatten() + .filter_map(|e| e.ok()?.file_name().into_string().ok()) + .filter(|n| is_blob_hex(n)) + .collect(); + out.sort(); + out + } + fn manifest_type_path(&self, name: &str, hex: &str) -> PathBuf { self.root .join("repos") @@ -1265,6 +1512,39 @@ const MAX_MANIFEST_BYTES: usize = 4 << 20; /// ask for (references × repositories the caller may read). const MAX_MANIFEST_REFERENCES: usize = 4096; +/// The layer media type of a file stored as one blob under a single-layer manifest — what +/// `/upload` and `/dav/files/` write, and what `vk registry pull` refuses: a raw file is for +/// fetching, not for booting. +pub(crate) const RAW_FILE_MEDIA_TYPE: &str = "application/vnd.virtkit.raw-file"; +pub(crate) const RAW_FILE_CONFIG_MEDIA_TYPE: &str = + "application/vnd.virtkit.raw-file.config.v1+json"; + +/// A raw-file manifest's config blob: fixed, empty content — a raw file has no build +/// config, but the OCI manifest schema requires a config descriptor. Every raw file +/// references the *same* config blob, which dedups after the first one. +pub(crate) const RAW_FILE_EMPTY_CONFIG: &[u8] = b"{}"; + +/// The layer of a raw-file manifest as `(hex, canonical size)`, or `None` for a manifest of +/// any other shape: an image pushed over `/v2/` into a `files/` repository is not a file +/// the WebDAV view serves. +pub(crate) fn raw_file_layer(manifest: &[u8]) -> Option<(String, u64)> { + let v: serde_json::Value = serde_json::from_slice(manifest).ok()?; + let [layer] = v.pointer("/layers")?.as_array()?.as_slice() else { + return None; + }; + if layer.pointer("/mediaType")?.as_str()? != RAW_FILE_MEDIA_TYPE { + return None; + } + let hex = layer + .pointer("/digest")? + .as_str()? + .strip_prefix("sha256:")?; + if !is_blob_hex(hex) { + return None; + } + Some((hex.to_string(), layer.pointer("/size")?.as_u64()?)) +} + /// What a [`Store::gc`] pass removed (or, on a dry run, would remove). #[derive(Default)] pub struct GcReport { @@ -1892,6 +2172,12 @@ async fn route(req: Request, state: Arc) -> Result` or the OCI repository name. Keep it outside + // `is_human_path` so clients receive a 401 challenge, not a login redirect. + if path == "/dav" || path.starts_with("/dav/") { + return dav::route(&state, &authz, req).await; + } let store = state.store.clone(); let method = req.method().clone(); let query = req.uri().query().unwrap_or("").to_string(); @@ -3094,8 +3380,7 @@ fn percent_decode(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -/// `vk registry gc` — collect `root` and print a one-line summary; see -/// [`Store::gc`] for the retention model. +/// Collect garbage and print its summary. pub fn gc(root: PathBuf, retention: Duration, grace: Duration, dry_run: bool) -> Result<()> { let Some(store) = Store::open(&root)? else { println!( diff --git a/vk-registry/src/upload.rs b/vk-registry/src/upload.rs index ec9887f2..15cb1e95 100644 --- a/vk-registry/src/upload.rs +++ b/vk-registry/src/upload.rs @@ -31,14 +31,7 @@ use hyper::{Method, Request, Response, StatusCode}; use crate::accounts::{self, Db, Principal}; use crate::html::{self, page, respond}; use crate::{Body, body_of}; -use crate::{DEFAULT_MANIFEST_TYPE, Store, html_escape, valid_name, valid_tag}; - -/// The manifest's config blob: fixed, empty content — a raw-file upload has no build -/// config, but the OCI manifest schema requires a config descriptor. Every upload -/// therefore references the *same* config blob, which dedups after the first one. -const EMPTY_CONFIG: &[u8] = b"{}"; -const RAW_FILE_MEDIA_TYPE: &str = "application/vnd.virtkit.raw-file"; -const RAW_FILE_CONFIG_MEDIA_TYPE: &str = "application/vnd.virtkit.raw-file.config.v1+json"; +use crate::{Store, html_escape, valid_name, valid_tag}; /// The largest file this form accepts. The bytes are held in memory to hash them, so /// this is a real ceiling and not a formality; anything bigger belongs in @@ -315,8 +308,8 @@ async fn submit( .map_err(Into::into) } -/// The blob, the shared empty config, and the single-layer manifest tying them together -/// — the same three writes a `/v2/` push makes. +/// The blob, then the shared empty config and the single-layer manifest tying them together +/// ([`Store::put_raw_file`]) — the same three writes a `/v2/` push makes. fn store_upload( store: &Store, name: &str, @@ -325,41 +318,15 @@ fn store_upload( file_name: Option<&str>, ) -> Result<()> { let _lock = store.lock_shared()?; - let config_digest = store.put_blob(EMPTY_CONFIG)?; let layer_digest = store.put_blob(file_bytes)?; - // These bytes arrived through this form, for this repository, so they are readable - // through it. `put_manifest` records only the manifest itself — a reference is not - // evidence that the referrer holds the content — so the two blobs are recorded here, - // where we do hold them. - for digest in [&config_digest, &layer_digest] { - store.record_blob(name, digest.trim_start_matches("sha256:"))?; - } - let mut layer = serde_json::json!({ - "mediaType": RAW_FILE_MEDIA_TYPE, - "digest": layer_digest, - "size": file_bytes.len(), - }); // Only when the browser actually sent one: an empty title is worse than none. - if let Some(title) = file_name.map(clamp_name).filter(|t| !t.is_empty()) { - layer["annotations"] = serde_json::json!({ - "org.opencontainers.image.title": title, - }); - } - let manifest = serde_json::json!({ - "schemaVersion": 2, - "mediaType": DEFAULT_MANIFEST_TYPE, - "config": { - "mediaType": RAW_FILE_CONFIG_MEDIA_TYPE, - "digest": config_digest, - "size": EMPTY_CONFIG.len(), - }, - "layers": [layer], - }); - store.put_manifest( + let title = file_name.map(clamp_name).filter(|t| !t.is_empty()); + store.put_raw_file( name, tag, - DEFAULT_MANIFEST_TYPE, - serde_json::to_vec(&manifest)?.as_slice(), + layer_digest.trim_start_matches("sha256:"), + file_bytes.len() as u64, + title.as_deref(), )?; Ok(()) } diff --git a/vk-registry/tests/dav_e2e.rs b/vk-registry/tests/dav_e2e.rs new file mode 100644 index 00000000..cab262e8 --- /dev/null +++ b/vk-registry/tests/dav_e2e.rs @@ -0,0 +1,1246 @@ +//! WebDAV tests over real HTTP: the sccache/opendal request sequence, listings, OCI downloads, +//! authorization, invalid paths and upload limits. Requests are constructed directly without an +//! opendal dependency. + +use std::sync::Arc; +use std::time::SystemTime; + +use sha2::Digest as _; +use vk_registry::accounts::{Action, Db, Scope}; +use vk_registry::config::{AuthMode, OidcSpec}; +use vk_registry::{Authenticator, ServerConfig, ServerState}; + +const MANIFEST_TYPE: &str = "application/vnd.oci.image.manifest.v1+json"; +const FILES_ALLOW: &str = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL"; +const READ_ALLOW: &str = "OPTIONS, GET, HEAD, PROPFIND"; + +fn tmp(tag: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!( + "vk-registry-dav-e2e-{tag}-{}-{:?}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&p); + p +} + +/// Run serve_on in a separate thread with an ephemeral listener. +fn spawn(state: Arc) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let l = tokio::net::TcpListener::from_std(listener).unwrap(); + let _ = vk_registry::serve_on(l, state).await; + }); + }); + format!("http://{addr}") +} + +/// Server without credentials for protocol tests. +fn open_state(dir: &std::path::Path) -> Arc { + let cfg = ServerConfig::local("127.0.0.1:5000".parse().unwrap(), dir.join("store")); + Arc::new(cfg.into_state().expect("a local config starts")) +} + +/// Accounts-mode state built the way `serve` builds it — the same helper as in +/// `accounts_e2e.rs` and `upload_e2e.rs`. +fn accounts_state(dir: &std::path::Path) -> Arc { + std::fs::create_dir_all(dir).unwrap(); + let secret = dir.join("oidc-secret"); + std::fs::write(&secret, "s3cr3t\n").unwrap(); + let mut cfg = ServerConfig::local("127.0.0.1:5000".parse().unwrap(), dir.join("store")); + cfg.mode = AuthMode::Accounts; + cfg.oidc = Some(OidcSpec { + issuer: "https://login.example.com".to_string(), + client_id: "vk-registry".to_string(), + client_secret_file: secret, + public_url: "https://registry.internal".to_string(), + }); + Arc::new(cfg.into_state().expect("a valid accounts config starts")) +} + +fn accounts_db(state: &ServerState) -> &Db { + match &state.auth { + Authenticator::Accounts { db, .. } => db, + _ => panic!("not accounts mode"), + } +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +fn method(name: &str) -> reqwest::Method { + reqwest::Method::from_bytes(name.as_bytes()).unwrap() +} + +/// A `PROPFIND` as opendal sends it: the `Depth` header and the `allprop` body. +async fn propfind(c: &reqwest::Client, url: &str, depth: &str) -> reqwest::Response { + c.request(method("PROPFIND"), url) + .header("Depth", depth) + .header("Content-Type", "application/xml") + .body("") + .send() + .await + .unwrap() +} + +/// Send a raw HTTP request and return its status and response. Raw sockets preserve traversal +/// paths that URL parsers normalize. `extra` contains headers; the body is empty. +async fn raw(addr: &str, request_line: &str, extra: &str) -> (u16, String) { + let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap(); + let request = + format!("{request_line} HTTP/1.1\r\nHost: {addr}\r\n{extra}Connection: close\r\n\r\n"); + tokio::io::AsyncWriteExt::write_all(&mut sock, request.as_bytes()) + .await + .unwrap(); + let mut answer = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut sock, &mut answer) + .await + .unwrap(); + let answer = String::from_utf8_lossy(&answer).into_owned(); + let status = answer + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("no status in {answer}")); + (status, answer) +} + +/// Files in `uploads/`, where a WebDAV `PUT` stages its object: what is in flight, and what +/// a failed one left behind. +fn staging_left(root: &std::path::Path) -> usize { + std::fs::read_dir(root.join("uploads")).map_or(0, |d| { + d.filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) + .count() + }) +} + +/// Where an object's tag lives: `repos/files//tags/` under the store root. +fn tag_path(root: &std::path::Path, key: &str) -> std::path::PathBuf { + let (dirs, leaf) = key.rsplit_once('/').unwrap(); + root.join("repos/files").join(dirs).join("tags").join(leaf) +} + +/// Bytes zstd cannot shrink, so a blob made of them is stored in identity form. +fn incompressible(n: usize) -> Vec { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x as u8 + }) + .collect() +} + +/// Test the initial opendal upload sequence: missing-parent PROPFINDs, MKCOLs, PUT, properties +/// and GET. +#[tokio::test] +async fn the_opendal_write_then_read_sequence_round_trips() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("sequence"); + let state = open_state(&dir); + let root = dir.join("store"); + let url = spawn(state); + let c = client(); + + // The startup probe: a GET that is allowed to 404, then a PUT that decides whether + // sccache runs read-write or read-only. The PUT is also what brings the directory + // into being. + let resp = c + .get(format!("{url}/dav/files/sccache/.sccache_check")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404); + let resp = c + .put(format!("{url}/dav/files/sccache/.sccache_check")) + .body("probe") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "a writable directory accepts the probe"); + + // opendal's writer: PROPFIND the parent, walking up while it 404s. No directory is + // special: the walk climbs to `files/`, which is what answers. + for p in [ + "/dav/files/other/a/b", + "/dav/files/other/a", + "/dav/files/other", + ] { + assert_eq!( + propfind(&c, &format!("{url}{p}"), "0").await.status(), + 404, + "{p}" + ); + } + let resp = propfind(&c, &format!("{url}/dav/files"), "0").await; + assert_eq!(resp.status(), 207, "files/ itself answers"); + let body = resp.text().await.unwrap(); + assert!( + body.contains(""), + "{body}" + ); + assert!(body.contains("/dav/files/"), "{body}"); + + // then MKCOL back down, the top-level directory included + for p in [ + "/dav/files/other", + "/dav/files/other/a", + "/dav/files/other/a/b", + ] { + let resp = c + .request(method("MKCOL"), format!("{url}{p}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "MKCOL {p}"); + } + // a directory that is already there is 405, not an error the writer retries on + let resp = c + .request(method("MKCOL"), format!("{url}/dav/files/other/a")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405); + assert_eq!(resp.headers()["allow"], FILES_ALLOW); + + // the object itself + let key = "/dav/files/other/a/b/abc123"; + let payload = vec![7u8; 300_000]; + let resp = c + .put(format!("{url}{key}")) + .body(payload.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + assert!( + tag_path(&root, "other/a/b/abc123").is_file(), + "the object is a tag of the repository its directories name" + ); + assert_eq!( + staging_left(&root), + 0, + "a completed PUT leaves no staging file" + ); + + // PROPFIND it: the fields opendal's Multistatus deserializer reads. + let resp = propfind(&c, &format!("{url}{key}"), "0").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert!( + body.contains("HTTP/1.1 200 OK"), + "{body}" + ); + assert!(body.contains(""), "{body}"); + assert!( + body.contains(&format!( + "{}", + payload.len() + )), + "{body}" + ); + assert!(body.contains(""), "{body}"); + assert!( + body.contains(" GMT"), + "RFC 1123: {body}" + ); + assert!(body.contains(&format!("{key}")), "{body}"); + + // Verify download bytes and headers that prevent uploaded content from rendering. + let resp = c.get(format!("{url}{key}")).send().await.unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers()["content-type"], "application/octet-stream"); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); + assert_eq!(resp.headers()["content-disposition"], "attachment"); + assert!(resp.headers().contains_key("last-modified")); + assert_eq!(resp.headers()["content-length"], payload.len().to_string()); + assert_eq!(resp.bytes().await.unwrap().as_ref(), payload.as_slice()); + + // HEAD is the same answer without the body. + let resp = c.head(format!("{url}{key}")).send().await.unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers()["content-length"], payload.len().to_string()); + assert!(resp.bytes().await.unwrap().is_empty()); + + // OPTIONS advertises the DAV class opendal looks for. + let resp = c + .request(reqwest::Method::OPTIONS, format!("{url}/dav/files/other")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers()["dav"], "1"); + + // Replacement returns 204. Use a larger body to catch responses sent before the upload + // drains, which can reset the connection. + let replacement = vec![9u8; 8 << 20]; + let resp = c + .put(format!("{url}{key}")) + .body(replacement.clone()) + .send() + .await + .expect("a repeated PUT is answered, not reset"); + assert_eq!(resp.status(), 204); + let resp = c.get(format!("{url}{key}")).send().await.unwrap(); + assert_eq!( + resp.headers()["content-length"], + replacement.len().to_string() + ); + assert_eq!(resp.bytes().await.unwrap().as_ref(), replacement.as_slice()); + // The one early answer left, a PUT onto a directory, still reads the body through. + let resp = c + .put(format!("{url}/dav/files/other/a")) + .body(vec![9u8; 8 << 20]) + .send() + .await + .expect("a PUT onto a directory is answered, not reset"); + assert_eq!(resp.status(), 405); + + // A directory is not readable: listings are PROPFIND's. + assert_eq!( + c.get(format!("{url}/dav/files/other/a")) + .send() + .await + .unwrap() + .status(), + 404 + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// Depth 1 lists the collection and its children. DELETE removes files and empty directories. +#[tokio::test] +async fn a_directory_lists_its_members_and_is_deleted_only_when_empty() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("listing"); + let state = open_state(&dir); + let url = spawn(state); + let c = client(); + + for (p, n) in [("/dav/files/d/x/one", 10), ("/dav/files/d/x/two", 20)] { + let resp = c + .put(format!("{url}{p}")) + .body(vec![1u8; n]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "{p}"); + } + assert_eq!( + c.request(method("MKCOL"), format!("{url}/dav/files/d/x/sub")) + .send() + .await + .unwrap() + .status(), + 201 + ); + + let resp = propfind(&c, &format!("{url}/dav/files/d/x/"), "1").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert_eq!(body.matches("").count(), 4, "{body}"); + assert_eq!(body.matches("").count(), 4, "{body}"); + assert!(body.contains("/dav/files/d/x/"), "{body}"); + assert!( + body.contains("/dav/files/d/x/sub/"), + "{body}" + ); + assert!( + body.contains("/dav/files/d/x/one"), + "{body}" + ); + assert!( + body.contains("10"), + "{body}" + ); + assert!( + body.contains("20"), + "{body}" + ); + assert_eq!( + body.matches("").count(), + 2, + "the directory and its subdirectory: {body}" + ); + let first = body.find("/dav/files/d/x/").unwrap(); + let member = body.find("/dav/files/d/x/one").unwrap(); + assert!(first < member, "the collection itself comes first: {body}"); + + // Depth 1 on a file is the file. + let resp = propfind(&c, &format!("{url}/dav/files/d/x/one"), "1").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert_eq!(body.matches("").count(), 1, "{body}"); + + // Listing the top level, on an open server, is the one enumeration refused. + assert_eq!( + propfind(&c, &format!("{url}/dav/files/"), "1") + .await + .status(), + 403 + ); + assert_eq!( + propfind(&c, &format!("{url}/dav/"), "1").await.status(), + 403 + ); + // Depth 0 there is fine: it is what opendal's parent walk asks. + assert_eq!( + propfind(&c, &format!("{url}/dav/"), "0").await.status(), + 207 + ); + + // DELETE: a directory with members is refused, an empty one and an object go. + let del = async |p: &str| c.delete(format!("{url}{p}")).send().await.unwrap().status(); + assert_eq!(del("/dav/files/d/x").await, 403); + assert_eq!(del("/dav/files/d/x/sub").await, 204); + assert_eq!(del("/dav/files/d/x/one").await, 204); + assert_eq!(del("/dav/files/d/x/one").await, 404); + assert_eq!(del("/dav/files/d/x/two").await, 204); + assert_eq!(del("/dav/files/d/x").await, 204); + assert_eq!(del("/dav/files/d").await, 204, "a top-level directory too"); + assert_eq!( + propfind(&c, &format!("{url}/dav/files/d"), "0") + .await + .status(), + 404 + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reject unsupported depth, unsafe paths, reserved names, unsupported verbs and oversized +/// bodies. +#[tokio::test] +async fn the_files_tree_refuses_what_it_does_not_serve() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("refusals"); + let state = open_state(&dir); + let root = dir.join("store"); + let url = spawn(state); + let c = client(); + + assert_eq!( + c.request(method("MKCOL"), format!("{url}/dav/files/sccache")) + .send() + .await + .unwrap() + .status(), + 201 + ); + + // Depth infinity is refused; an absent Depth is the RFC's `infinity` but opendal's + // `0`, and it is served as 0; a malformed one is a bad request. + let resp = propfind(&c, &format!("{url}/dav/files/sccache"), "infinity").await; + assert_eq!(resp.status(), 403); + let resp = c + .request(method("PROPFIND"), format!("{url}/dav/files/sccache")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 207); + assert_eq!( + propfind(&c, &format!("{url}/dav/files/sccache"), "2") + .await + .status(), + 400 + ); + + // Reject encoded separators, control bytes, empty components, and what the OCI name + // rules refuse: characters outside `[A-Za-z0-9._-]`, and the layout's own names. + for p in [ + "/dav/files/sccache/a%2F..%2Fb", + "/dav/files/sccache/a%5C..%5Cb", + "/dav/files/sccache/a%00b", + "/dav/files/sccache//x", + "/dav/files/sccache/x//y", + "/dav/files/bad%20dir/x", + "/dav/files/sccache/tags/x", + "/dav/files/sccache/a/blobs", + ] { + let resp = c.get(format!("{url}{p}")).send().await.unwrap(); + assert_eq!(resp.status(), 400, "GET {p}"); + let resp = c.put(format!("{url}{p}")).body("x").send().await.unwrap(); + assert_eq!(resp.status(), 400, "PUT {p}"); + } + // An area that is not one of the two. + assert_eq!( + c.get(format!("{url}/dav/nope/x")) + .send() + .await + .unwrap() + .status(), + 404 + ); + + // Send literal and encoded traversal over raw sockets to bypass client URL normalization. + let addr = url.trim_start_matches("http://"); + for p in [ + "/dav/files/sccache/../../etc/passwd", + "/dav/files/sccache/%2E%2E/%2E%2E/etc/passwd", + "/dav/files/sccache/a/%2E%2E/%2E%2E/%2E%2E/root", + "/dav/../v2/", + ] { + for verb in ["GET", "PUT", "PROPFIND", "MKCOL", "DELETE"] { + let (status, answer) = raw(addr, &format!("{verb} {p}"), "").await; + assert_eq!(status, 400, "{verb} {p}: {answer}"); + } + } + + // A verb this tree does not speak names the ones it does. + for verb in ["PROPPATCH", "COPY", "MOVE", "LOCK"] { + let resp = c + .request(method(verb), format!("{url}/dav/files/sccache/x")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405, "{verb}"); + assert_eq!(resp.headers()["allow"], FILES_ALLOW, "{verb}"); + } + // The roots take no writes at all. + for p in ["/dav/files", "/dav/"] { + let resp = c + .request(method("MKCOL"), format!("{url}{p}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405, "{p}"); + assert_eq!(resp.headers()["allow"], READ_ALLOW, "{p}"); + } + + // A PROPFIND body far past the drain cap is refused rather than buffered. + let resp = c + .request(method("PROPFIND"), format!("{url}/dav/files/sccache")) + .body("x".repeat(128 * 1024)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + + // A PUT onto a directory is not an overwrite. + let resp = c + .put(format!("{url}/dav/files/sccache")) + .body("x") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405); + + assert_eq!( + staging_left(&root), + 0, + "no refusal left a staging file behind" + ); + assert!( + !root.join("repos/files/bad dir").exists() + && !root.join("repos/files/sccache/tags/x").exists(), + "a refused request created nothing" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reject Content-Length over the object cap before reading the body, leaving no staging file. +/// Use a raw request to avoid sending 4 GiB; unit tests cover the streaming cap with smaller +/// bodies. +#[tokio::test] +async fn an_object_over_the_cap_is_refused_and_leaves_no_staging_file() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("cap"); + let state = open_state(&dir); + let root = dir.join("store"); + let url = spawn(state); + let over = (4u64 << 30) + 1; + let (status, answer) = raw( + url.trim_start_matches("http://"), + "PUT /dav/files/sccache/x/y/big", + &format!("Content-Length: {over}\r\n"), + ) + .await; + assert_eq!(status, 413, "an over-cap PUT is refused: {answer}"); + + // Verify an object below the cap succeeds. + let resp = client() + .put(format!("{url}/dav/files/sccache/x/y/ok")) + .body(vec![0u8; 8 << 20]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + assert!(!tag_path(&root, "sccache/x/y/big").exists()); + assert!(tag_path(&root, "sccache/x/y/ok").is_file()); + assert_eq!( + staging_left(&root), + 0, + "neither the refusal nor the upload left a staging file" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Verify 401 challenges in both auth modes, read-only file access and repository-scoped +/// listings. +#[tokio::test] +async fn the_dav_tree_is_gated_and_scoped_like_every_other_family() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("authz"); + + // Shared-secret mode with a token configured. + let token = dir.join("token"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&token, "s3cr3t\n").unwrap(); + let mut cfg = ServerConfig::local("127.0.0.1:5000".parse().unwrap(), dir.join("shared")); + cfg.token_file = Some(token); + let shared_url = spawn(Arc::new(cfg.into_state().unwrap())); + + // Accounts mode, with a key that may only read `files/`, one that writes one + // directory, and one that reads one team's repositories. + let adir = dir.join("accounts"); + let state = accounts_state(&adir); + let db = accounts_db(&state); + let user = db.upsert_user("https://issuer", "ci", None, None).unwrap(); + let key = |name: &str, action: Action, pattern: &str| { + db.create_api_key( + Some(&user.id), + name, + &[Scope { + action, + repo_pattern: pattern.to_string(), + }], + None, + ) + .unwrap() + .1 + }; + let read_key = key("ci-read", Action::Read, "files/*"); + let write_key = key("ci-write", Action::Write, "files/sccache"); + let team_key = key("team-a", Action::Read, "team-a/*"); + // Two teams' repositories, stored the way `/v2/` stores them. + let store = state.store.clone(); + let blob_a = store.put_blob(b"layer of team a").unwrap(); + let blob_b = store.put_blob(b"layer of team b").unwrap(); + let hex_a = blob_a.trim_start_matches("sha256:").to_string(); + let hex_b = blob_b.trim_start_matches("sha256:").to_string(); + store.record_blob("team-a/app", &hex_a).unwrap(); + store.record_blob("team-b/app", &hex_b).unwrap(); + for name in ["team-a/app", "team-b/app"] { + store + .put_manifest(name, "v1", MANIFEST_TYPE, br#"{"schemaVersion":2}"#) + .unwrap(); + } + let accounts_url = spawn(state.clone()); + let c = client(); + + // Unauthenticated, in both modes: the bare 401 with a challenge on it, not a redirect. + for url in [&shared_url, &accounts_url] { + for p in ["/dav/files/x/y", "/dav/repos/team-a/app/tags/v1", "/dav/"] { + let resp = c.get(format!("{url}{p}")).send().await.unwrap(); + assert_eq!(resp.status(), 401, "GET {p} at {url}"); + assert!( + resp.headers().contains_key("www-authenticate"), + "no challenge for {p} at {url}" + ); + let resp = propfind(&c, &format!("{url}{p}"), "0").await; + assert_eq!(resp.status(), 401, "PROPFIND {p} at {url}"); + } + let resp = c + .put(format!("{url}/dav/files/x/y")) + .body("x") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401, "at {url}"); + } + + // A configured shared credential is trusted with the whole store, roots included. + let resp = c + .request(method("PROPFIND"), format!("{shared_url}/dav/")) + .header("Depth", "1") + .bearer_auth("s3cr3t") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert!(body.contains("/dav/repos/"), "{body}"); + assert!(body.contains("/dav/files/"), "{body}"); + + // The write key seeds an object; then the read-only key may read it and not write. + let resp = c + .put(format!("{accounts_url}/dav/files/sccache/probe")) + .bearer_auth(&write_key) + .body("cached") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "a write grant writes"); + let resp = c + .get(format!("{accounts_url}/dav/files/sccache/probe")) + .bearer_auth(&read_key) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "a read grant reads"); + assert_eq!(resp.text().await.unwrap(), "cached"); + // The startup probe a read-only pipeline sends: refused, which is what puts sccache + // in read-only mode instead of failing the job. + let resp = c + .put(format!("{accounts_url}/dav/files/sccache/.sccache_check")) + .bearer_auth(&read_key) + .body("probe") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403, "a read grant does not write"); + // Nor may the write key touch another directory: its grant names one. + let resp = c + .put(format!("{accounts_url}/dav/files/other/x")) + .bearer_auth(&write_key) + .body("x") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403); + // The `files/` listing shows the read key what it may read. + let resp = c + .request(method("PROPFIND"), format!("{accounts_url}/dav/files/")) + .header("Depth", "1") + .bearer_auth(&read_key) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert!( + body.contains("/dav/files/sccache/"), + "{body}" + ); + + // The `repos/` view, scope-filtered: the team key lists its team and nothing else. + let listing = async |key: &str, path: &str| { + let resp = c + .request(method("PROPFIND"), format!("{accounts_url}{path}")) + .header("Depth", "1") + .bearer_auth(key) + .send() + .await + .unwrap(); + (resp.status().as_u16(), resp.text().await.unwrap()) + }; + let (status, body) = listing(&team_key, "/dav/repos/").await; + assert_eq!(status, 207); + assert!( + body.contains("/dav/repos/team-a/"), + "{body}" + ); + assert!(!body.contains("team-b"), "{body}"); + let (status, body) = listing(&team_key, "/dav/repos/team-a/").await; + assert_eq!(status, 207); + assert!( + body.contains("/dav/repos/team-a/app/"), + "{body}" + ); + let (status, body) = listing(&team_key, "/dav/repos/team-a/app/tags/").await; + assert_eq!(status, 207); + assert!( + body.contains("/dav/repos/team-a/app/tags/v1"), + "{body}" + ); + // The other team's repository does not exist, as far as this key can tell. + let (status, _) = listing(&team_key, "/dav/repos/team-b/").await; + assert_eq!(status, 404); + let resp = c + .get(format!("{accounts_url}/dav/repos/team-b/app/tags/v1")) + .bearer_auth(&team_key) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404); + // The write key's one scope is a `files/` directory, which the `repos/` view shows as + // the repository it is — and nothing beside it. + let (status, body) = listing(&write_key, "/dav/repos/").await; + assert_eq!(status, 207); + assert_eq!(body.matches("").count(), 2, "{body}"); + assert!( + body.contains("/dav/repos/files/"), + "{body}" + ); + + // A blob is readable through a repository only when that repository holds it. + let resp = c + .get(format!("{accounts_url}/dav/repos/team-a/app/blobs/{hex_a}")) + .bearer_auth(&team_key) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.bytes().await.unwrap().as_ref(), b"layer of team a"); + let resp = c + .get(format!("{accounts_url}/dav/repos/team-a/app/blobs/{hex_b}")) + .bearer_auth(&team_key) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 404, + "a digest team-a does not hold is not there" + ); + let resp = propfind( + &c, + &format!("{accounts_url}/dav/repos/team-a/app/blobs/{hex_b}"), + "0", + ) + .await; + assert_eq!(resp.status(), 401, "no key, no view"); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// Verify OCI tags, manifests and blobs download correctly, including decoded zstd blobs and +/// canonical lengths. Reject all writes. +#[tokio::test] +async fn the_repos_view_reads_back_what_v2_stored() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("repos"); + let state = open_state(&dir); + let store = state.store.clone(); + let url = spawn(state); + let c = client(); + + // Two blobs: one zstd shrinks (stored as a frame), one it cannot (stored as is). + let packed = vec![7u8; 100_000]; + let plain = incompressible(50_000); + let packed_hex = store + .put_blob(&packed) + .unwrap() + .trim_start_matches("sha256:") + .to_string(); + let plain_hex = store + .put_blob(&plain) + .unwrap() + .trim_start_matches("sha256:") + .to_string(); + assert!( + dir.join("store/blobs/zstd").join(&packed_hex).is_file(), + "the compressible blob is stored as a frame" + ); + assert!( + dir.join("store/blobs/sha256").join(&plain_hex).is_file(), + "the incompressible one as itself" + ); + store.record_blob("demo", &packed_hex).unwrap(); + store.record_blob("demo", &plain_hex).unwrap(); + let manifest = format!( + r#"{{"schemaVersion":2,"layers":[{{"digest":"sha256:{packed_hex}","size":{}}},{{"digest":"sha256:{plain_hex}","size":{}}}]}}"#, + packed.len(), + plain.len() + ); + let digest = store + .put_manifest("demo", "v1", MANIFEST_TYPE, manifest.as_bytes()) + .unwrap(); + let manifest_hex = digest.trim_start_matches("sha256:").to_string(); + + // The repository, and what is under it. + let resp = propfind(&c, &format!("{url}/dav/repos/demo/"), "1").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + for sub in ["tags", "manifests", "blobs"] { + assert!( + body.contains(&format!("/dav/repos/demo/{sub}/")), + "{sub}: {body}" + ); + } + // Tags: the manifest's length and type on the entry. + let resp = propfind(&c, &format!("{url}/dav/repos/demo/tags/"), "1").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert!( + body.contains("/dav/repos/demo/tags/v1"), + "{body}" + ); + assert!( + body.contains(&format!( + "{}", + manifest.len() + )), + "{body}" + ); + assert!( + body.contains(&format!( + "{MANIFEST_TYPE}" + )), + "{body}" + ); + // Manifests by digest. + let resp = propfind(&c, &format!("{url}/dav/repos/demo/manifests/"), "1").await; + let body = resp.text().await.unwrap(); + assert!( + body.contains(&format!( + "/dav/repos/demo/manifests/{manifest_hex}" + )), + "{body}" + ); + // Blobs: the canonical length for both, the frame header read for the packed one. + let resp = propfind(&c, &format!("{url}/dav/repos/demo/blobs/"), "1").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert_eq!(body.matches("").count(), 3, "{body}"); + assert!( + body.contains(&format!( + "{}", + packed.len() + )), + "{body}" + ); + assert!( + body.contains(&format!( + "{}", + plain.len() + )), + "{body}" + ); + assert_eq!(body.matches("").count(), 3, "{body}"); + + // Downloads: the tag and the manifest are the same bytes with the stored type. + for p in [ + "/dav/repos/demo/tags/v1".to_string(), + format!("/dav/repos/demo/manifests/{manifest_hex}"), + ] { + let resp = c.get(format!("{url}{p}")).send().await.unwrap(); + assert_eq!(resp.status(), 200, "{p}"); + assert_eq!(resp.headers()["content-type"], MANIFEST_TYPE, "{p}"); + assert_eq!(resp.headers()["docker-content-digest"], digest, "{p}"); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff", "{p}"); + assert_eq!(resp.text().await.unwrap(), manifest, "{p}"); + } + // The packed blob comes back decoded, its length exact and known before the body. + let resp = c + .get(format!("{url}/dav/repos/demo/blobs/{packed_hex}")) + .header("Accept-Encoding", "identity") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers()["content-length"], packed.len().to_string()); + assert!(!resp.headers().contains_key("content-encoding")); + assert_eq!(resp.bytes().await.unwrap().as_ref(), packed.as_slice()); + let resp = c + .head(format!("{url}/dav/repos/demo/blobs/{packed_hex}")) + .header("Accept-Encoding", "identity") + .send() + .await + .unwrap(); + assert_eq!(resp.headers()["content-length"], packed.len().to_string()); + let resp = c + .get(format!("{url}/dav/repos/demo/blobs/{plain_hex}")) + .header("Accept-Encoding", "identity") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.bytes().await.unwrap().as_ref(), plain.as_slice()); + // A single resource's PROPFIND says the same length. + let resp = propfind(&c, &format!("{url}/dav/repos/demo/blobs/{packed_hex}"), "0").await; + assert_eq!(resp.status(), 207); + let body = resp.text().await.unwrap(); + assert!( + body.contains(&format!( + "{}", + packed.len() + )), + "{body}" + ); + + // Absent things are absent; collections have no body. + for p in [ + "/dav/repos/nope/", + "/dav/repos/demo/tags/v2", + &format!("/dav/repos/demo/blobs/{}", "0".repeat(64)), + "/dav/repos/demo/blobs/notahex", + "/dav/repos/tags/", + ] { + assert_eq!( + propfind(&c, &format!("{url}{p}"), "0").await.status(), + 404, + "PROPFIND {p}" + ); + } + for p in ["/dav/repos/demo/", "/dav/repos/demo/tags/", "/dav/repos/"] { + assert_eq!( + c.get(format!("{url}{p}")).send().await.unwrap().status(), + 404, + "GET {p}" + ); + } + // Enumerating repositories on an open server is refused; naming one is not. + assert_eq!( + propfind(&c, &format!("{url}/dav/repos/"), "1") + .await + .status(), + 403 + ); + assert_eq!( + propfind(&c, &format!("{url}/dav/repos/"), "0") + .await + .status(), + 207 + ); + + // Nothing under `repos/` takes a write. + for (verb, p) in [ + ("PUT", "/dav/repos/demo/tags/v2"), + ("MKCOL", "/dav/repos/new"), + ("DELETE", "/dav/repos/demo/tags/v1"), + ("PUT", &format!("/dav/repos/demo/blobs/{plain_hex}")), + ("PROPPATCH", "/dav/repos/demo/"), + ] { + let resp = c + .request(method(verb), format!("{url}{p}")) + .body("x") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405, "{verb} {p}"); + assert_eq!(resp.headers()["allow"], READ_ALLOW, "{verb} {p}"); + } + // and the tag is still there + assert_eq!( + c.get(format!("{url}/dav/repos/demo/tags/v1")) + .send() + .await + .unwrap() + .status(), + 200 + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// GET refreshes mtimes older than an hour; HEAD does not. Last-Modified reports the pre-touch +/// value. +#[tokio::test] +async fn a_get_refreshes_an_idle_objects_mtime_and_a_head_does_not() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("touch"); + let state = open_state(&dir); + let root = dir.join("store"); + let url = spawn(state); + let c = client(); + + for name in ["got", "headed", "fresh"] { + let resp = c + .put(format!("{url}/dav/files/cache/{name}")) + .body("x") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + } + // Idle since 1994 — an instant with a known RFC 1123 spelling. The record is the + // tag's mtime, what the gc's retention reads. + let long_ago = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(784_887_151); + for name in ["got", "headed"] { + std::fs::File::open(tag_path(&root, &format!("cache/{name}"))) + .unwrap() + .set_modified(long_ago) + .unwrap(); + } + let mtime = |name: &str| { + std::fs::metadata(tag_path(&root, &format!("cache/{name}"))) + .unwrap() + .modified() + .unwrap() + }; + let fresh_before = mtime("fresh"); + + let resp = c + .get(format!("{url}/dav/files/cache/got")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let reported = resp.headers()["last-modified"] + .to_str() + .unwrap() + .to_string(); + assert_eq!(resp.text().await.unwrap(), "x"); + assert!( + mtime("got") > SystemTime::now() - std::time::Duration::from_secs(60), + "a hit on an idle object refreshes it" + ); + // The header is the pre-touch time. + assert_eq!(reported, "Tue, 15 Nov 1994 08:12:31 GMT"); + + let resp = c + .head(format!("{url}/dav/files/cache/headed")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(mtime("headed"), long_ago, "a HEAD is not a use"); + + let resp = c + .get(format!("{url}/dav/files/cache/fresh")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!( + mtime("fresh"), + fresh_before, + "a fresh record is not rewritten" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// An object is a tag, so the gc's retention is what expires it; a PUT after that starts +/// over, and a shared blob is what the two objects have in common. +#[tokio::test] +async fn an_idle_object_goes_with_the_gc_and_a_put_starts_over() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("gc"); + let state = open_state(&dir); + let store = state.store.clone(); + let root = dir.join("store"); + let url = spawn(state); + let c = client(); + + for key in ["cache/ab/cd/key1", "cache/ab/cd/key2"] { + let resp = c + .put(format!("{url}/dav/files/{key}")) + .body("the same bytes") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + } + // Two objects, one blob: the pool dedups what the plain-file area could not. + let hex: String = sha2::Sha256::digest(b"the same bytes") + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert!(store.has_blob(&hex)); + assert!(store.repo_has_blob("files/cache/ab/cd", &hex)); + + std::fs::File::open(tag_path(&root, "cache/ab/cd/key1")) + .unwrap() + .set_modified(SystemTime::now() - std::time::Duration::from_secs(40 * 86_400)) + .unwrap(); + let day = std::time::Duration::from_secs(86_400); + let r = store.gc(30 * day, day, false).unwrap(); + assert_eq!(r.tags_dropped, 1); + assert_eq!(r.blobs_dropped, 0, "key2 still references the bytes"); + assert!(!tag_path(&root, "cache/ab/cd/key1").exists()); + assert_eq!( + c.get(format!("{url}/dav/files/cache/ab/cd/key1")) + .send() + .await + .unwrap() + .status(), + 404 + ); + let resp = c + .get(format!("{url}/dav/files/cache/ab/cd/key2")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await.unwrap(), "the same bytes"); + + let resp = c + .put(format!("{url}/dav/files/cache/ab/cd/key1")) + .body("v2") + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "a dropped object is created, not replaced" + ); + assert!(tag_path(&root, "cache/ab/cd/key1").is_file()); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reject top-level PUTs without blocking later directory creation. +#[tokio::test] +async fn a_put_at_a_top_level_name_is_refused_before_the_directory_exists() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("toplevel"); + let state = open_state(&dir); + let root = dir.join("store").join("repos/files"); + let staging = dir.join("store"); + let url = spawn(state); + let c = client(); + + for p in ["/dav/files/cache", "/dav/files/cache/"] { + let resp = c + .put(format!("{url}{p}")) + .body("payload".repeat(1024)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405, "PUT {p}"); + assert_eq!(resp.headers()["allow"], FILES_ALLOW, "PUT {p}"); + assert!( + std::fs::symlink_metadata(root.join("cache")).is_err(), + "PUT {p} created nothing at the directory's name" + ); + } + assert_eq!( + staging_left(&staging), + 0, + "a refused top-level PUT left nothing in staging" + ); + + // PUT also fails after MKCOL. + assert_eq!( + c.request(method("MKCOL"), format!("{url}/dav/files/cache")) + .send() + .await + .unwrap() + .status(), + 201 + ); + let resp = c + .put(format!("{url}/dav/files/cache")) + .body("payload".repeat(1024)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 405); + assert!( + root.join("cache/tags").is_dir(), + "MKCOL made an empty repository" + ); + + // An object below it is what the directory is for. + let resp = c + .put(format!("{url}/dav/files/cache/key")) + .body("payload") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + assert!(root.join("cache/tags/key").is_file()); + assert_eq!(staging_left(&staging), 0, "the upload left no staging file"); + let _ = std::fs::remove_dir_all(&dir); +} From fa0e30c2ed32379c044f4ef3d618dacf05068bc1 Mon Sep 17 00:00:00 2001 From: Antoine Bernardeau Date: Fri, 11 Sep 2026 09:55:47 +0000 Subject: [PATCH 2/3] doc: document registry WebDAV access --- README.md | 14 +++-- vk-registry/DESIGN.md | 119 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5312a8d8..7f8aa4e5 100644 --- a/README.md +++ b/README.md @@ -605,9 +605,15 @@ boundaries and interpretation. ### Caching and registries Local image conversion and build caches require no server. `vk-registry` is optional and -is useful when several runners need a shared OCI store, pull-through cache, or build-once -coordination. Its lease and heartbeat protocol prevents runners from independently -building the same content while a healthy peer is already doing so. +is useful when several runners need a shared OCI store, pull-through cache, build-once +coordination, or a shared compiler cache. The whole store is also served over WebDAV under +`/dav/`, behind the same TLS and credentials as the rest of the server: `/dav/repos/` is a +read-only view of every repository's tags, manifests and blobs, and `/dav/files/` is a +plain-file area where an `sccache` pointed at `/dav/files/` lets jobs in throwaway +microVMs reuse each other's compiled units; the objects are stored in the OCI pool under +`files/` and expire under `vk-registry gc`'s tag retention like any other tag. Its +lease and heartbeat protocol prevents runners from independently building the same content +while a healthy peer is already doing so. Use `vk registry push|pull|inspect` for guest bundles and `vk registry status|gc` for a local store. The central server and storage model are documented in @@ -619,7 +625,7 @@ local store. The central server and storage model are documented in | --- | --- | | `vk` | Host CLI, VMM, image builder, userspace network, compose runner, and GitLab executor. It embeds the default guest kernel and `vk-agent`. | | `vk-agent` | Guest PID 1 and command server. It configures mounts, networking, hostname, shared directories, optional SSH, and host-driven execution over vsock. | -| `vk-registry` | Optional OCI-distribution server with a pull-through cache, shared build cache, and build-once locking. | +| `vk-registry` | Optional OCI-distribution server with a pull-through cache, a WebDAV view of the store with a plain-file area for compiler caches, and build-once locking. | | `vk-runnerctl` | Optional root-side helper that adjusts GitLab runner concurrency within an administrator-configured range. | ## Architecture diff --git a/vk-registry/DESIGN.md b/vk-registry/DESIGN.md index 8317a827..9ba09fae 100644 --- a/vk-registry/DESIGN.md +++ b/vk-registry/DESIGN.md @@ -1,11 +1,13 @@ # vk-registry design -`vk-registry` is a central OCI Distribution server for virtkit runners. It provides three +`vk-registry` is a central OCI Distribution server for virtkit runners. It provides four services behind one listener: - a content-addressed OCI store shared by all runners; -- a pull-through cache for upstream registries; and -- a leased lock service that coordinates build-once work across runners. +- a pull-through cache for upstream registries; +- a leased lock service that coordinates build-once work across runners; and +- a WebDAV view of the store, with a plain-file area that build caches such as + `sccache`'s write to — stored in the same pool as everything else. The server is intended to run on a dedicated host or as a user service. Local virtkit use does not require it: `vk` uses the same `Store` implementation directly for its default @@ -19,8 +21,8 @@ scope. ## Architecture The `vk-registry` crate contains both the reusable store library and the server binary. -The library provides the store, OCI routes, pull-through relay, build lock service, and -authentication. In accounts mode it also provides OIDC login, browser, upload, and local +The library provides the store, OCI routes, pull-through relay, build lock service, WebDAV +view, and authentication. In accounts mode it also provides OIDC login, browser, upload, and local administration surfaces. The binary provides `serve`, `status`, `gc`, `install-service`, `accounts`, and `update`. @@ -42,11 +44,15 @@ Repository names and tags are metadata over that shared blob pool. repos//tags/ manifest digest referenced by the tag repos//manifests/ manifest media type and repository membership repos//blobs/ blob membership marker - uploads/ in-progress upload + uploads/ in-progress upload, or a WebDAV PUT being staged uploads/owners/ repository that opened the upload accounts/accounts.db account data, when accounts mode is enabled ``` +The plain-file area `/dav/files/` has no storage of its own: an object is a tag on a +raw-file manifest in a repository under `files/`, and its bytes a blob in the pool. See +"WebDAV view". + The `sha256` and `zstd` directories are two physical encodings of the same logical namespace. A digest always identifies the uncompressed bytes. Deduplication is therefore independent of whether a client or the server performed compression. @@ -388,6 +394,94 @@ The normal build-once sequence for content key `K` is: 5. Build and push on a miss, or record the failure. 6. Release the lease. +## WebDAV view (`/dav/`) + +The whole store is reachable over WebDAV under one root, through the registry's existing +listener, TLS and client auth, with the permission model the OCI API enforces. The verb set +is what opendal's `webdav` service issues — the client behind `sccache`, `oli` and other +opendal-based tools: `PROPFIND` at `Depth` 0 and 1, `GET`, `HEAD`, `PUT`, `MKCOL`, `DELETE` +and `OPTIONS`. `Depth: infinity` is refused with 403, as RFC 4918 allows; `COPY`, `MOVE`, +`LOCK` and `PROPPATCH` are 405. No client XML is parsed: a `PROPFIND` body is drained and +ignored, `allprop` being both what the client sends and what an empty body means. + +```text +/dav/ repos/ files/ +/dav/repos// tags/ manifests/ blobs/ (+ nested repositories) +/dav/repos//tags/ the manifest the tag resolves to, with its media type +/dav/repos//manifests/ that manifest +/dav/repos//blobs/ the blob's canonical bytes; a stored zstd frame is decoded +/dav/files// plain files: repositories and tags under files/, read-write +``` + +**`repos/` is a read-only view, not an export.** The disk tree is not what a client wants: +a stored blob may be a zstd frame, a tag file holds a digest rather than a manifest, and the +blob pool is readable per repository, not as one directory. So tags and manifests download +as the manifest bytes with their media type and blobs as their canonical bytes, through the +same handlers `/v2/` uses and under the same authorization — `Read` on the repository, and +membership for anything addressed by digest. The tree is derived from the list of +repositories the principal may read, never from a `read_dir` of `repos/`: a repository the +principal cannot read is a 404, as `/browse` answers, and a scope such as `read:team-a/*` +lists `team-a/` and nothing beside it. Every write verb there is 405: an OCI write verifies +a digest, records membership and holds the store lock, and a DAV client cannot supply a +manifest's media type. Those go through `/v2/`. A listing of `blobs/` carries each member's +canonical length, which for a zstd-stored blob is one `open` and a frame-header read; a +repository of chunked bundles lists thousands of members, so that listing is an +interactive operation and never on a CI path. + +**`files/` is a directory tree over the pool.** A path's directories are a repository under +`files/` and its leaf a tag on a single-layer raw-file manifest, the shape `/upload` writes: +`/dav/files/sccache/a/b/abcdef` is the tag `abcdef` of the repository `files/sccache/a/b`, +and its bytes are the layer blob. An object therefore dedups, compresses and is collected +like any other content, and is reachable over `/v2/` and `/browse` under that name. A +top-level directory authorizes as the repository `files/` for the whole tree below it: +`GET`, `HEAD`, `PROPFIND` and `OPTIONS` are reads, `PUT`, `MKCOL` and `DELETE` writes, so +scopes such as `write:files/*` or `read:files/sccache` apply unchanged. Nothing in the +server knows what `sccache` is; it is a directory that a compiler cache happens to write to. +Every component obeys the OCI name rules — `[A-Za-z0-9._-]`, at most 16 components counting +`files` and ``, none named `tags`, `manifests` or `blobs` — and anything else is 400. A +tag whose manifest is not a raw file, an image pushed over `/v2/` into a `files/` +repository, is absent from this view. + +| Verb on `files/` | Answer | +|---|---| +| `GET`, `HEAD` | 200 with the layer blob (`application/octet-stream`, `nosniff`, `Content-Disposition: attachment`, `Last-Modified` from the tag); a directory or a missing path is 404. A `GET` refreshes the tag's mtime once it is an hour old — the use the gc's retention keys on; a `HEAD` does not | +| `PUT` | streamed to `uploads/` and hashed on the way, then promoted into the pool and tied to the tag by a manifest under the shared store lock — 201 when the tag is new, 204 when it is replaced, the previous content left for the gc; 413 past 4 GiB; 405 for directories and top-level names, after draining the body | +| `PROPFIND` | 207 for an object or a directory; at `Depth: 1` the nested repositories and the tags follow it, each object with `getcontentlength` (the layer's canonical size) and `getlastmodified` (the tag's), which opendal requires; 404 when absent | +| `MKCOL` | 201, creating an empty repository (a `tags/` with nothing in it) and its missing ancestors — a top-level directory included, which is how one comes to exist; 405 when a directory is at the name; 409 when an object is | +| `DELETE` | 204 for an object (the tag goes; the bytes wait for the gc) or a directory holding no tag and no repository; 403 for a directory with members; 404 when absent | +| `OPTIONS` | `DAV: 1` and the `Allow` list; every other verb is 405 with the list the resource serves | + +A `PUT` answered early still reads its body through: a status sent with request bytes still +unread closes the socket with a reset, and `sccache` takes a reset on its startup probe as +an unwritable store and runs the whole build read-only. A read-only key makes that probe +fail with 403, which the client reports as read-only mode: an untrusted pipeline consumes +the cache without writing to it. Since a writer with `Write` on a directory owns its +content, give write access only to trusted pipelines (protected branches), hand everything +else a read-only key, and use one directory per trust level when that is not enough. + +Paths are split on raw `/` before each component is percent-decoded on its own, so `%2F` +cannot smuggle a separator; `.`, `..`, empty, over-long (255 bytes) and control-byte +components, and depth past 32 (16 under `files/`, the repository name bound), are refused. +Hrefs in a 207 are rebuilt from the decoded +components, percent-encoded, with a trailing slash on a collection. `/dav/` is not a human +path, so an unauthenticated client gets the 401 challenge rather than a login redirect. + +**Enumeration** is the one disclosure a listing makes beyond what the caller named. A +`Depth: 1` on `/dav/`, `/dav/repos/`, `/dav/files/` or a path component above repositories +shows only what the principal may read, and on a server with no credential configured at +all it is refused with 403, for the reason `/browse` does not exist in shared-secret mode: +a catalog is not something anyone who can reach the port gets for free. `Depth: 0` there, +which is what opendal's parent walk asks, always answers. + +### Lifecycle + +An object is a tag, so `gc` expires it as it expires every tag: dropped once idle past the +retention window, its blobs swept once unreferenced and past the grace window, both windows +store-wide. Idleness is the tag's mtime, which a `PUT` sets and a `GET` refreshes once it is an +hour old, so a read-only pipeline's hits keep an entry alive as a writer's do. There is no +per-directory policy and no size cap; a replaced or deleted object frees no disk until the +next `gc`. A staging file a crashed `PUT` left in `uploads/` goes with the idle uploads. + ## Accounts administration `vk-registry accounts` manages users, sessions, administrators, and API keys. The command @@ -464,6 +558,9 @@ phase does not traverse child manifests. The pass aborts before deleting anythin indexes can be stored and mounted, but a store containing a live tagged index cannot be collected until the mark phase supports that graph. +The `/dav/files/` objects are tags and blobs under `files/` and need no pass of their own: +`gc` and `status` treat them as the repositories they are. + ## Guest credential proxy With `vk run --registry-proxy` or `[registry] proxy_guests = true`, the host starts a @@ -481,6 +578,16 @@ layers. The feature is opt-in and requires guest networking. - The lock manager and accounts database assume one server process. Multi-replica operation requires a distributed lock implementation and a replicated account store. - Pull-through cache eviction is retention-based; there is no size-capped LRU policy. +- A `files/` directory has no eviction until an operator attaches a policy to it; there is + no default, by design (see "Eviction policy"). Setting a policy needs access to the host + holding the store; an admin-gated HTTP route for it is a natural addition in accounts mode + and does not exist yet. +- The `files/` area trusts its writers: a stored compiler-cache entry is linked into every + project computing the same key, so write access belongs to trusted pipelines only (see + "WebDAV view"). +- The store directory must be writable only by trusted local users. Requests and eviction + can follow symlinks in parent directories under `files/`; checks reject only a symlink at + the final path component. Preventing this requires descriptor-relative path resolution. - Chunk boundaries are client-defined. Clients using different chunkers share the blob pool but may not deduplicate the same artifact effectively. - Expired sessions are removed when presented, not by a periodic sweep. From 98f9c9fba995eafa1ff1b33d9bafe463127e85ea Mon Sep 17 00:00:00 2001 From: Antoine Bernardeau Date: Sat, 12 Sep 2026 09:07:59 +0000 Subject: [PATCH 3/3] registry: allow disabling WebDAV Add `webdav = false` to disable WebDAV access. Enabled by default. --- CHANGELOG.md | 3 +- README.md | 7 ++-- vk-driver/src/registry.rs | 5 +++ vk-registry/DESIGN.md | 9 +++-- vk-registry/src/config.rs | 8 ++++ vk-registry/src/config/help.rs | 7 ++++ vk-registry/src/lib.rs | 8 +++- vk-registry/tests/accounts_e2e.rs | 2 + vk-registry/tests/dav_e2e.rs | 61 +++++++++++++++++++++++++++++++ vk-registry/tests/exists_e2e.rs | 1 + vk-registry/tests/relay_e2e.rs | 16 ++++++++ 11 files changed, 119 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2accb2e..13f8d226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ All notable changes to virtkit will be documented in this file. share one cache of compiled units over the registry's existing TLS and credentials; a read-only credential gives a pipeline the hits without letting it write. Objects are stored in the OCI pool as raw-file manifests under `files//…`, dedup and compress - with everything else, and expire under `gc`'s tag retention like any other tag. + with everything else, and expire under `gc`'s tag retention like any other tag. WebDAV is + enabled by default; set `webdav = false` in the server config to disable it. ## [0.72.0] - 2026-09-15 diff --git a/README.md b/README.md index 7f8aa4e5..4c9a538c 100644 --- a/README.md +++ b/README.md @@ -611,9 +611,10 @@ coordination, or a shared compiler cache. The whole store is also served over We read-only view of every repository's tags, manifests and blobs, and `/dav/files/` is a plain-file area where an `sccache` pointed at `/dav/files/` lets jobs in throwaway microVMs reuse each other's compiled units; the objects are stored in the OCI pool under -`files/` and expire under `vk-registry gc`'s tag retention like any other tag. Its -lease and heartbeat protocol prevents runners from independently building the same content -while a healthy peer is already doing so. +`files/` and expire under `vk-registry gc`'s tag retention like any other tag. Set +`webdav = false` in the server config to disable WebDAV. Its lease and +heartbeat protocol prevents runners from independently building the same content while +a healthy peer is already doing so. Use `vk registry push|pull|inspect` for guest bundles and `vk registry status|gc` for a local store. The central server and storage model are documented in diff --git a/vk-driver/src/registry.rs b/vk-driver/src/registry.rs index 5883a4fc..d7ded986 100644 --- a/vk-driver/src/registry.rs +++ b/vk-driver/src/registry.rs @@ -3216,6 +3216,7 @@ mod tests { locks: vk_registry::lock::LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let rg = Registry::for_share(url, true, None, String::new(), None, None, None); assert_eq!( @@ -3266,6 +3267,7 @@ mod tests { locks: vk_registry::lock::LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); // plain HTTP on loopback, which `for_share`'s `insecure` flag is for. The directory // push is the path that reaches `push_file`, and it goes through a `Config`. @@ -3520,6 +3522,7 @@ mod tests { locks: vk_registry::lock::LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let remote = Registry::for_share(url, true, None, String::new(), None, None, None); for rg in [&local, &remote] { @@ -3558,6 +3561,7 @@ mod tests { locks: vk_registry::lock::LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let remote = Registry::for_share(url, true, None, String::new(), None, None, None); // Manifest PUT returns a `Location` URL. Callers need the digest to pin @@ -3591,6 +3595,7 @@ mod tests { locks: vk_registry::lock::LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let remote = Registry::for_share(url, true, None, String::new(), None, None, None); // A dense parent, then an untouched overlay with one dirty cluster: the diff diff --git a/vk-registry/DESIGN.md b/vk-registry/DESIGN.md index 9ba09fae..99fc864e 100644 --- a/vk-registry/DESIGN.md +++ b/vk-registry/DESIGN.md @@ -397,9 +397,12 @@ The normal build-once sequence for content key `K` is: ## WebDAV view (`/dav/`) The whole store is reachable over WebDAV under one root, through the registry's existing -listener, TLS and client auth, with the permission model the OCI API enforces. The verb set -is what opendal's `webdav` service issues — the client behind `sccache`, `oli` and other -opendal-based tools: `PROPFIND` at `Depth` 0 and 1, `GET`, `HEAD`, `PUT`, `MKCOL`, `DELETE` +listener, TLS and client auth, with the permission model the OCI API enforces. WebDAV is +enabled by default. Set `webdav = false` to disable it: `/dav/` requests return 404 after +authentication, and the server logs this setting at startup. + +The verb set is what opendal's `webdav` service issues — the client behind `sccache`, `oli` +and other opendal-based tools: `PROPFIND` at `Depth` 0 and 1, `GET`, `HEAD`, `PUT`, `MKCOL`, `DELETE` and `OPTIONS`. `Depth: infinity` is refused with 403, as RFC 4918 allows; `COPY`, `MOVE`, `LOCK` and `PROPPATCH` are 405. No client XML is parsed: a `PROPFIND` body is drained and ignored, `allprop` being both what the client sends and what an empty body means. diff --git a/vk-registry/src/config.rs b/vk-registry/src/config.rs index d94b79db..3ec865b4 100644 --- a/vk-registry/src/config.rs +++ b/vk-registry/src/config.rs @@ -82,6 +82,9 @@ pub struct ServerConfig { /// `[oidc]`, required in `mode = "accounts"` — it is the only login path that mode /// has. pub oidc: Option, + /// Enable `/dav/` (default: true). When disabled, requests return 404 after auth. + /// File eviction continues regardless. + pub webdav: bool, } /// The `[oidc]` config table, as declared (before its client secret is read and checked @@ -133,6 +136,8 @@ struct FileConfig { accounts_db: Option, /// `false` to bind no admin socket, a path to move it, `true` for the default one. admin_socket: Option, + /// Enable `/dav/`; defaults to true. + webdav: Option, oidc: Option, #[serde(default)] upstream: Vec, @@ -225,6 +230,7 @@ impl ServerConfig { accounts_db: None, admin_socket: AdminSocket::Unset, oidc: None, + webdav: true, } } @@ -401,6 +407,7 @@ impl ServerConfig { client_secret_file: o.client_secret_file, public_url: o.public_url, }), + webdav: f.webdav.unwrap_or(true), }; // Also here, not only in `build_auth`: `load` is where a file becomes a config, so // a contradictory file is refused by parsing it at all, not only by the path that @@ -658,6 +665,7 @@ impl ServerConfig { locks: LockManager::new(), auth, tls: None, + webdav: self.webdav, }) } } diff --git a/vk-registry/src/config/help.rs b/vk-registry/src/config/help.rs index e8102812..9547bb60 100644 --- a/vk-registry/src/config/help.rs +++ b/vk-registry/src/config/help.rs @@ -87,6 +87,11 @@ const KEYS: &[Key] = &[ downtime; false binds none, in accounts mode only\n\ [default: admin.sock beside accounts_db]", }, + Key { + table: Table::Top, + name: "webdav", + help: "enable /dav/; false returns 404 [default: true]", + }, Key { table: Table::Top, name: "oidc", @@ -152,6 +157,7 @@ root = \"/srv/vk-registry\" tls_cert = \"/etc/vk-registry/fullchain.pem\" tls_key = \"/etc/vk-registry/privkey.pem\" token_file = \"/etc/vk-registry/token\" +webdav = true [[upstream]] prefix = \"docker.io\" @@ -170,6 +176,7 @@ tls_cert = \"/etc/vk-registry/fullchain.pem\" tls_key = \"/etc/vk-registry/privkey.pem\" mode = \"accounts\" accounts_db = \"/srv/vk-registry/accounts/accounts.db\" +webdav = true [oidc] issuer = \"https://id.example.com\" diff --git a/vk-registry/src/lib.rs b/vk-registry/src/lib.rs index af79b41e..39bb76a4 100644 --- a/vk-registry/src/lib.rs +++ b/vk-registry/src/lib.rs @@ -88,6 +88,8 @@ pub struct ServerState { pub locks: lock::LockManager, pub auth: Authenticator, pub tls: Option, + /// Enable WebDAV routes. + pub webdav: bool, } impl ServerState { @@ -1892,6 +1894,9 @@ pub async fn serve_on(listener: TcpListener, state: Arc) -> Result< ) ); } + if !state.webdav { + eprintln!("vk-registry: WebDAV off (webdav = false): /dav/ answers 404"); + } loop { let (stream, _peer) = listener.accept().await.context("accept")?; let state = state.clone(); @@ -2175,7 +2180,8 @@ async fn route(req: Request, state: Arc) -> Result` or the OCI repository name. Keep it outside // `is_human_path` so clients receive a 401 challenge, not a login redirect. - if path == "/dav" || path.starts_with("/dav/") { + // Disabled WebDAV routes fall through to 404. + if state.webdav && (path == "/dav" || path.starts_with("/dav/")) { return dav::route(&state, &authz, req).await; } let store = state.store.clone(); diff --git a/vk-registry/tests/accounts_e2e.rs b/vk-registry/tests/accounts_e2e.rs index e2faa9f6..da0ea3c7 100644 --- a/vk-registry/tests/accounts_e2e.rs +++ b/vk-registry/tests/accounts_e2e.rs @@ -1342,6 +1342,7 @@ async fn a_relayed_blob_becomes_a_member_of_the_repo_it_was_fetched_for() { locks: LockManager::new(), auth: Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); // Mirror: accounts mode, empty store, everything routed upstream. @@ -1442,6 +1443,7 @@ async fn shared_secret_mode_is_unchanged_by_repo_scoping() { locks: LockManager::new(), auth: Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let client = no_redirect_client(); diff --git a/vk-registry/tests/dav_e2e.rs b/vk-registry/tests/dav_e2e.rs index cab262e8..01f8f617 100644 --- a/vk-registry/tests/dav_e2e.rs +++ b/vk-registry/tests/dav_e2e.rs @@ -1244,3 +1244,64 @@ async fn a_put_at_a_top_level_name_is_refused_before_the_directory_exists() { assert_eq!(staging_left(&staging), 0, "the upload left no staging file"); let _ = std::fs::remove_dir_all(&dir); } + +/// WebDAV defaults to enabled. Disabling it returns 404 for DAV requests while OCI requests +/// still work. +#[tokio::test] +async fn webdav_false_turns_the_dav_tree_off_and_nothing_else() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let dir = tmp("webdav-off"); + std::fs::create_dir_all(&dir).unwrap(); + + // Check the default and both explicit values. + for (text, want) in [ + ("", true), + ("webdav = true\n", true), + ("webdav = false\n", false), + ] { + let path = dir.join("cfg.toml"); + std::fs::write(&path, format!("root = {:?}\n{text}", dir.join("store"))).unwrap(); + let cfg = ServerConfig::load(&path, None, None).expect("a valid config"); + assert_eq!(cfg.webdav, want, "{text:?}"); + } + + let mut cfg = ServerConfig::local("127.0.0.1:5000".parse().unwrap(), dir.join("store")); + cfg.webdav = false; + let state = Arc::new(cfg.into_state().expect("a local config starts")); + let url = spawn(state); + let c = client(); + + for (verb, path) in [ + ("PROPFIND", "/dav/"), + ("PROPFIND", "/dav/files/"), + ("PUT", "/dav/files/sccache/.sccache_check"), + ("GET", "/dav/files/sccache/.sccache_check"), + ("MKCOL", "/dav/files/sccache"), + ("OPTIONS", "/dav/files/sccache"), + ("GET", "/dav/repos/"), + ("PROPFIND", "/dav"), + ] { + let resp = c + .request(method(verb), format!("{url}{path}")) + .header("Depth", "0") + .body("x") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404, "{verb} {path}"); + assert!( + !resp.headers().contains_key("dav"), + "{verb} {path} must not advertise DAV" + ); + } + assert!( + !dir.join("store/repos/files").exists(), + "nothing under files/ came into being" + ); + + // OCI remains available. + let resp = c.get(format!("{url}/v2/")).send().await.unwrap(); + assert_eq!(resp.status(), 200); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/vk-registry/tests/exists_e2e.rs b/vk-registry/tests/exists_e2e.rs index d1315592..b2e26926 100644 --- a/vk-registry/tests/exists_e2e.rs +++ b/vk-registry/tests/exists_e2e.rs @@ -60,6 +60,7 @@ async fn batch_probe_answers_every_tag_in_order() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let http = reqwest::Client::new(); let exists = format!("{url}{}", vk_registry::EXISTS_PATH); diff --git a/vk-registry/tests/relay_e2e.rs b/vk-registry/tests/relay_e2e.rs index 2c7b4590..53956f9d 100644 --- a/vk-registry/tests/relay_e2e.rs +++ b/vk-registry/tests/relay_e2e.rs @@ -94,6 +94,7 @@ async fn relay_caches_digest_not_tag() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let up_url = spawn(up_state); @@ -112,6 +113,7 @@ async fn relay_caches_digest_not_tag() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let mirror_url = spawn(mirror_state); let http = reqwest::Client::new(); @@ -183,6 +185,7 @@ async fn multi_lock_is_atomic_all_or_nothing() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let url = spawn(state); let c = LockClient::new(url, vk_registry::ClientAuth::None, reqwest::Client::new()); @@ -256,6 +259,7 @@ async fn lock_client_authenticates_with_basic() { pass: "p".into(), }), tls: None, + webdav: true, }); let url = spawn(state); let ttl = Duration::from_secs(30); @@ -297,6 +301,7 @@ async fn lock_client_authenticates_with_bearer() { token: "s3cret".into(), }), tls: None, + webdav: true, }); let url = spawn(state); let ttl = Duration::from_secs(30); @@ -334,6 +339,7 @@ async fn lock_client_round_trips_against_the_server() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let url = spawn(state); let c = LockClient::new(url, vk_registry::ClientAuth::None, reqwest::Client::new()); @@ -400,6 +406,7 @@ async fn bearer_auth_gates_everything_including_the_probe() { token: "s3cret".to_string(), }), tls: None, + webdav: true, }); let url = spawn(state); let http = reqwest::Client::new(); @@ -482,6 +489,7 @@ async fn basic_auth_gates_and_challenges() { pass: "p".to_string(), }), tls: None, + webdav: true, }); let url = spawn(state); let http = reqwest::Client::new(); @@ -696,6 +704,7 @@ async fn relay_does_not_leak_upstream_credentials_to_the_client() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let mirror_url = spawn(mirror_state); let http = reqwest::Client::new(); @@ -737,6 +746,7 @@ async fn lock_api_build_once_over_http() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let url = spawn(state); let http = reqwest::Client::new(); @@ -1038,6 +1048,7 @@ async fn browse_belongs_to_accounts_mode_and_redirects_a_signed_out_browser() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let open_url = spawn(open); let url = open_url.clone(); @@ -1242,6 +1253,7 @@ async fn a_relayed_manifests_content_type_is_held_to_a_manifest_type() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, }); let url = spawn(mirror); let http = reqwest::Client::new(); @@ -1328,6 +1340,7 @@ async fn a_blob_head_is_never_relayed() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let mirror_dir = tmp("head-mirror"); @@ -1344,6 +1357,7 @@ async fn a_blob_head_is_never_relayed() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let http = reqwest::Client::new(); let hex = bdigest.trim_start_matches("sha256:"); @@ -1408,6 +1422,7 @@ async fn a_blob_head_agrees_with_the_get_for_a_compressed_blob() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); let http = reqwest::Client::new(); @@ -1510,6 +1525,7 @@ async fn a_blob_larger_than_one_chunk_streams_back_intact() { locks: LockManager::new(), auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None), tls: None, + webdav: true, })); // This client is built without any compression feature, so it sends no