server/lib/src/raw.rs
//! Generic / **raw** writable backend: a disk-backed, path-addressed bucket for
//! arbitrary blobs — the Nexus/Artifactory "raw hosted repository" (the misc
//! bucket for tarballs, installers, reports, firmware, anything that has no
//! ecosystem protocol of its own). A client `PUT`s bytes at an arbitrary path
//! and `GET`s them back byte-identical; the path *is* the address.
//!
//! Deliberately the leanest writable backend: no index synthesis, no metadata
//! generation, no ecosystem parsing — just a safe, content-verifiable loose-file
//! store. Because it implements the full [`RepositoryBackendTrait`] it drops into
//! every existing seam unchanged: the checksum-deploy gate verifies uploads at
//! the HTTP door, cross-repo [`search`](crate::search) finds blobs by
//! name/path/checksum, the retention/GC executor reclaims them
//! ([`delete_artifact`](RawBackend::delete_artifact) is the SHA-256-verified safe
//! unlink, agreeing with `holger-server-lib::sha256_hex`), and
//! [`replication`](crate::replication) copies them content-id-idempotently.
//!
//! **Coordinate ↔ path bijection (the one rule both entry points obey).** The
//! gRPC / in-process side addresses a blob by an [`ArtifactId`]; the HTTP side
//! addresses it by a raw path. They meet at one deterministic mapping:
//! - `id → relpath` = the `/`-join of the non-empty parts of
//! `[namespace, name, version]`.
//! - [`list`](RawBackend::list) reports every stored file as
//! `ArtifactId { namespace: None, name: <relpath>, version: "" }`.
//!
//! So `id_to_relpath(listed_id) == <relpath>` round-trips exactly — which is why
//! a listing fed back into `fetch`/`delete_artifact` (what retention, GC,
//! replication and search all do) resolves to the very same file.
//!
//! **Traversal-closed.** Every path segment is validated ([`safe_relpath`]): an
//! empty segment, `.`, `..`, a backslash or a NUL is refused, so no id and no
//! HTTP path can climb out of `base_dir`. (This mirrors the per-segment guard the
//! sibling loose-file backends keep privately — `sanitize_repo` in `promote`
//! collapses to a *single* segment, which would destroy the nested paths a raw
//! bucket exists to hold, so it is intentionally not reused here.)
use crate::lock::LockOrRecover as _;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use anyhow::Context as _;
use traits::{ArtifactEntry, ArtifactFormat, ArtifactId, RepositoryBackendTrait};
/// A writable, disk-backed, path-addressed blob store (the "raw hosted repo").
/// The bounded buffer a streamed write or read moves the blob through: the
/// whole of what a request costs this process in memory, whatever the blob's
/// size.
const STREAM_BUF_BYTES: usize = 1024 * 1024;
pub struct RawBackend {
name: String,
base_dir: PathBuf,
/// Memoized `relpath → (stamp, sha256_hex)` for blobs whose bytes this
/// process has already had in hand — see [`RawBackend::peek_digest`].
digests: Mutex<HashMap<String, (FileStamp, String)>>,
}
/// Identity of a stored raw blob, used to decide whether a memoized digest is
/// still the digest OF THE BYTES ON DISK NOW.
///
/// Raw blobs are MUTABLE — unlike `file-rust`'s crate files, which are immutable
/// by the registry's own rules, a raw path can be overwritten. So the memo may
/// never be trusted on the strength of the path alone: `(len, mtime)` must still
/// match, and a blob with no readable modification time is not memoized at all,
/// because a stamp with no clock in it cannot be invalidated.
///
/// ⚠ The residual hole, named rather than hidden: an out-of-band overwrite that
/// lands the SAME length at the SAME mtime tick would keep a stale digest. It
/// cannot happen through holger's own door — every write goes through
/// [`RawBackend::write_relpath`], which re-memoizes — and it is the identical
/// hole `file-rust` has carried since it was written. What it is NOT is a
/// trust boundary: `ArtifactEntry::sha256`'s own contract says an absent or
/// present digest never means the bytes are trusted.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct FileStamp {
len: u64,
mtime: SystemTime,
}
/// The memo's memory bound, in entries.
///
/// A raw bucket can hold millions of blobs and this map would otherwise grow
/// with every one this process ever touched. At the cap the memo stops GROWING
/// rather than evicting: a full memo still answers for everything already in it,
/// and the only consequence of the cap is that a listing beyond it reports
/// `None` — which is exactly what the field means. Eviction would instead make
/// the answer depend on access order, which is worse than an honest unknown.
const DIGEST_MEMO_CAP: usize = 65_536;
/// Lowercase-hex SHA-256 of `data`. The content id the retention/GC executor
/// verifies before an unlink — SHA-256 (not blake3) so it agrees with
/// `holger-server-lib::sha256_hex` and the other loose-file backends.
fn sha256_hex(data: &[u8]) -> String {
// LAW #5 dedup: consume the shared `nornir-hash` leaf (edda) so this backend's
// content id agrees with `holger-server-lib::sha256_hex` and the siblings.
nornir_hash::sha256_hex(data)
}
/// Validate a `/`-delimited relative path and join it under `base_dir`.
///
/// Every segment must be a benign single directory level — a hostile segment
/// (empty, `.`, `..`, containing a backslash or a NUL byte) is refused, so the
/// resulting path can never escape `base_dir`. An empty path (no segments) is
/// itself invalid (a raw blob needs a name). Returns the joined absolute path.
fn safe_relpath(base_dir: &Path, relpath: &str) -> anyhow::Result<PathBuf> {
let mut out = base_dir.to_path_buf();
let mut any = false;
for seg in relpath.split('/') {
if seg.is_empty() || seg == "." || seg == ".." || seg.contains('\\') || seg.contains('\0') {
anyhow::bail!("invalid raw path segment {seg:?} in {relpath:?}");
}
out.push(seg);
any = true;
}
if !any {
anyhow::bail!("empty raw artifact path");
}
Ok(out)
}
impl RawBackend {
/// Build a raw repo named `name`, storing blobs under `base_dir`.
pub fn new(name: String, base_dir: PathBuf) -> Self {
Self {
name,
base_dir,
digests: Mutex::new(HashMap::new()),
}
}
/// Read a path's `(len, mtime)` stamp, or `None` when it has no readable
/// modification time — which is the one case the memo refuses to key on.
fn stamp_of(path: &Path) -> Option<FileStamp> {
let meta = std::fs::metadata(path).ok()?;
Some(FileStamp {
len: meta.len(),
mtime: meta.modified().ok()?,
})
}
/// Record the digest of bytes this process has in hand ANYWAY.
///
/// Called from the two places raw already holds a whole blob — a write and a
/// read — so the hash is the only added cost and it is paid once per blob
/// per change, never once per listing. This is what makes `unchanged`
/// reachable downstream: an upstream holger that has SERVED a fetch of a
/// blob can answer with that blob's digest the next time it is listed, and a
/// mirror that already has those bytes then does not re-fetch them.
fn memoize_digest(&self, relpath: &str, path: &Path, data: &[u8]) {
self.memoize_digest_hex(relpath, path, sha256_hex(data));
}
/// The same memo, for a digest computed WHILE the bytes streamed past —
/// the write side of `put_from_reader`, where the blob was never whole in
/// memory and so cannot be hashed after the fact without a second read.
fn memoize_digest_hex(&self, relpath: &str, path: &Path, hex: String) {
let Some(stamp) = Self::stamp_of(path) else {
return;
};
let mut memo = self.digests.lock_or_recover();
if memo.len() >= DIGEST_MEMO_CAP && !memo.contains_key(relpath) {
return;
}
memo.insert(relpath.to_string(), (stamp, hex));
}
/// Forget a path's digest — called on every unlink, so a later blob at the
/// same path can never inherit the dead one's answer.
fn forget_digest(&self, relpath: &str) {
{
let mut memo = self.digests.lock_or_recover();
memo.remove(relpath);
}
}
/// **The listing path's digest: a memo PEEK, and never a hash.**
///
/// The listing walk already holds each file's length and mtime, so this
/// costs a map lookup and two comparisons. It NEVER opens a file: a raw
/// listing that hashed its store would be O(bytes of the whole repository)
/// and that is the property the test
/// `a_raw_listing_never_hashes_a_blob_it_has_not_handled` exists to keep.
///
/// `None` means "not hashed yet by this process, or changed since", never
/// "no digest exists" and never "the bytes are untrusted".
fn peek_digest(&self, relpath: &str, len: u64, mtime: Option<SystemTime>) -> Option<String> {
let mtime = mtime?;
let memo = self.digests.lock_or_recover();
let (stamp, hex) = memo.get(relpath)?;
(stamp.len == len && stamp.mtime == mtime).then(|| hex.clone())
}
/// The one coordinate→path map: the `/`-join of the non-empty parts of
/// `[namespace, name, version]`. A raw blob's address is its path, so a
/// caller that leaves `namespace`/`version` empty simply addresses by `name`;
/// a listing round-trips because `list` puts the whole relpath in `name`.
fn id_to_relpath(id: &ArtifactId) -> String {
let mut parts: Vec<&str> = Vec::with_capacity(3);
if let Some(ns) = id.namespace.as_deref() {
if !ns.is_empty() {
parts.push(ns);
}
}
if !id.name.is_empty() {
parts.push(&id.name);
}
if !id.version.is_empty() {
parts.push(&id.version);
}
parts.join("/")
}
/// Read the bytes stored at a validated relpath (`None` if absent).
fn read_relpath(&self, relpath: &str) -> anyhow::Result<Option<Vec<u8>>> {
let path = safe_relpath(&self.base_dir, relpath)?;
match std::fs::read(&path) {
Ok(bytes) => {
// The bytes are in hand for the caller's sake anyway, so the
// digest costs one hash and nothing else. See `memoize_digest`.
self.memoize_digest(relpath, &path, &bytes);
Ok(Some(bytes))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// **Durably** store `data` at a validated relpath (write → fsync → rename →
/// fsync the directory).
///
/// # Atomic is not the same as durable, and this used to conflate them
///
/// The previous shape was `fs::write(tmp)` + `fs::rename(tmp, path)` with no
/// `fsync` anywhere. That is atomic **against a concurrent reader** — nobody
/// ever observes a half-written artifact — and it was documented as
/// "atomically store", which is why the gap read as finished. It is not durable
/// against **power loss**: both the file's data blocks and the rename itself can
/// still be nothing but page cache when the machine dies, and then the artifact
/// the door already answered `2xx` for is simply gone.
///
/// MEASURED 2026-08-15 on the installed appliance (`installer_iso_fleet_kvm::
/// served_content_survives_a_power_cycle`): boot 1 accepted a 4096-byte PUT with
/// a 2xx; the VM was power-cycled; boot 2 came up, served, and 404'd the blob
/// for 90 s. Scanning the 64 GiB raw disk afterwards found ZERO occurrences of
/// the artifact's filename, and two of `identity.pub` — so this was not a slow
/// boot, not a race in the prober, and not a reformatted data home. The
/// ceremony's write had landed and the registry's had not. An artifact server
/// whose pitch is immutable, auditable, content-addressed storage must not
/// acknowledge a write it can still lose; ack-before-durable is a correctness
/// bug here, not a tuning choice.
///
/// # Why THREE fsyncs
///
/// `sync_all` on the temp file makes the BYTES durable. It says nothing about
/// the directory entry, and the entry is created by the `rename` — so a crash
/// between the two leaves durable data no name points at. On FAT the entry is
/// only landed by fsync'ing the file AT ITS FINAL NAME (`fat_file_fsync`
/// writes the dirent of the fd being synced — syncing the temp name syncs an
/// entry the rename throws away; measured 2026-08-17, see step 3). fsync'ing
/// the parent directory after that covers the filesystems where the rename
/// lives in the directory instead. Dropping any of them leaves a window,
/// which is why they are not separable and live in one writer: every raw PUT
/// and every raw copy funnels through this function.
fn write_relpath(&self, relpath: &str, data: &[u8]) -> anyhow::Result<()> {
self.write_relpath_from(relpath, &mut std::io::Cursor::new(data))
}
/// The write path proper, fed from a reader: the blob is written to the
/// scratch file AS IT ARRIVES through a bounded buffer and hashed on the
/// way past, so a 1 GiB upload costs 1 MiB of this process's memory and one
/// pass over the bytes. The durability chain (steps 1-4 below) is unchanged.
/// A read error from `body` is the door refusing the upload (over the cap,
/// or a digest that did not match): the scratch file is unlinked and the
/// final name is never touched.
fn write_relpath_from(
&self,
relpath: &str,
body: &mut dyn std::io::Read,
) -> anyhow::Result<()> {
use std::io::Write as _;
let path = safe_relpath(&self.base_dir, relpath)?;
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("raw artifact path has no parent directory"))?;
std::fs::create_dir_all(parent)?;
// Unique temp name per concurrent write (PID + a process-wide counter) so
// two simultaneous puts into one dir can't race the same `.tmp`.
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = parent.join(format!(".{}.{}.tmp", std::process::id(), seq));
// 1. the BYTES, durable before anything can name them.
let hex = {
let mut f = std::fs::File::create(&tmp)?;
let mut hasher = <sha2::Sha256 as sha2::Digest>::new();
let mut buf = vec![0u8; STREAM_BUF_BYTES];
let filled = (|| -> anyhow::Result<()> {
loop {
let n = body.read(&mut buf)?;
if n == 0 {
break;
}
sha2::Digest::update(&mut hasher, &buf[..n]);
f.write_all(&buf[..n])?;
}
f.sync_all()?;
Ok(())
})();
if let Err(e) = filled {
drop(f);
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
hex::encode(sha2::Digest::finalize(hasher))
};
// 2. the NAME, atomically.
if let Err(e) = std::fs::rename(&tmp, &path) {
// Never leave the scratch file behind on a failed publish.
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
// 3. the FILE AT ITS FINAL NAME, durable — and this one is MANDATORY.
//
// MEASURED 2026-08-17, second round of the power-cycle proof, with a
// FRESHLY packed binary carrying steps 1–2 + the best-effort step 4:
// the blob's 4096 bytes WERE in the raw disk image (17 pattern hits at
// ~17.26 GB — step 1 did its job) and the GET still 404'd for 90 s
// after the reboot. The bytes survived; the NAME did not. On FAT a
// file's metadata IS its directory entry, and the kernel's
// `fat_file_fsync` writes the dirent OF THE FD BEING SYNCED — step 1
// synced the TEMP name's entry, which the rename then discarded in
// page cache. Syncing the file at its FINAL name is what lands the
// real entry (and on ext4/xfs it is a correct, cheap belt over the
// same window). Its failure is a real "your write is not durable yet"
// and maps to a 5xx — unlike step 4, this is fsync on a plain FILE fd,
// which every filesystem the data home can be implements.
{
let f = std::fs::File::open(&path)?;
f.sync_all()?;
}
// 4. the ANCESTOR DIRECTORY CHAIN, durable — every level, up to the
// filesystem root, and MANDATORY.
//
// MEASURED 2026-08-17, third round of the power-cycle proof, with
// steps 1–3 all in the driven binary: the blob's bytes were on disk
// AND the crash-surviving FAT tree had NO `store/` AT ALL — not a
// missing file, a missing SUBTREE. `create_dir_all` had made
// `store/bundles/persist` in page cache; syncing the file (step 3)
// flushes the file's OWN dirent, but `store`'s entry lives in the
// data home's ROOT directory, `bundles`' in `store`, and so on — and
// none of those clusters were ever flushed, so the power cut orphaned
// the whole tree and boot 2 honestly served 404s over durable bytes
// no name could reach.
//
// So: fsync every ancestor from the file's parent upward. The walk is
// bounded by path depth and stops at the root; crossing above the
// mount point costs a few no-op fsyncs on the initramfs and is
// cheaper than knowing where the mount is. This used to be ONE
// best-effort fsync of the immediate parent, argued as "failing the
// request costs the artifact" — the measurement inverted that: NOT
// failing the request costs the artifact while answering 2xx, which
// is the exact lie this function exists to not tell. Every
// filesystem the data home can be (vfat P3, ext4/xfs dev hosts,
// tmpfs CI) implements fsync on a directory fd.
for dir in path.ancestors().skip(1) {
if dir.as_os_str().is_empty() {
break;
}
let d = std::fs::File::open(dir).with_context(|| {
format!("open ancestor dir {} to sync the rename", dir.display())
})?;
d.sync_all().with_context(|| {
format!(
"fsync ancestor dir {} — without it the artifact's name can \
evaporate on power loss while its bytes survive",
dir.display()
)
})?;
}
// The bytes just written are the bytes on disk, and they are in hand —
// so the digest is free here in the only sense that matters: no second
// read. Memoized AFTER the durability chain, so a blob whose name never
// landed never leaves a digest claiming it did.
self.memoize_digest_hex(relpath, &path, hex);
Ok(())
}
/// Unlink the file at a validated relpath. `Ok(true)` if a file was removed,
/// `Ok(false)` if there was nothing there (operator DELETE is idempotent-ish:
/// the caller maps absence to 404).
fn remove_relpath(&self, relpath: &str) -> anyhow::Result<bool> {
let path = safe_relpath(&self.base_dir, relpath)?;
match std::fs::remove_file(&path) {
Ok(()) => {
// A later blob at this path must never inherit the dead one's
// digest. The `(len, mtime)` stamp would almost always catch it;
// "almost always" is not a guarantee, and an unlink knows.
self.forget_digest(relpath);
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
/// The repo-name-stripped, query-stripped relpath of an HTTP suburl, or
/// `None` if the first segment is not this repo (never our route).
fn http_relpath(&self, suburl: &str) -> Option<String> {
let path = suburl.split('?').next().unwrap_or(suburl);
let mut segs = path
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty());
match segs.next() {
Some(first) if first == self.name => {
let rest: Vec<&str> = segs.collect();
if rest.is_empty() {
None
} else {
Some(rest.join("/"))
}
}
_ => None,
}
}
/// Recursively collect stored files as `(relpath, size, modified)` under
/// `base_dir`, capped at `cap`, optionally filtered to relpaths CONTAINING
/// `filter`.
///
/// The modification time is carried out of the `metadata` call the size
/// already needed — no extra syscall — and is `None` when the platform or
/// the filesystem does not answer, because a listing says "I do not know"
/// rather than inventing a time.
#[allow(clippy::type_complexity)]
fn walk(
&self,
filter: Option<&str>,
cap: usize,
) -> anyhow::Result<Vec<(String, u64, Option<std::time::SystemTime>)>> {
let mut out = Vec::new();
// Stack of directories still to visit (relative-prefix, absolute-path).
let mut stack: Vec<(String, PathBuf)> = vec![(String::new(), self.base_dir.clone())];
while let Some((prefix, dir)) = stack.pop() {
let iter = match std::fs::read_dir(&dir) {
Ok(it) => it,
// A missing base_dir (nothing stored yet) is an empty listing.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
for entry in iter {
let entry = entry?;
let file_name = match entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue, // non-UTF-8 name: skip gracefully
};
// Skip in-flight `.<pid>.<seq>.tmp` staging files.
if file_name.starts_with('.') && file_name.ends_with(".tmp") {
continue;
}
let rel = if prefix.is_empty() {
file_name.clone()
} else {
format!("{prefix}/{file_name}")
};
let ft = entry.file_type()?;
if ft.is_dir() {
stack.push((rel, entry.path()));
} else if ft.is_file() && filter.map(|f| rel.contains(f)).unwrap_or(true) {
let meta = entry.metadata().ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let modified = meta.and_then(|m| m.modified().ok());
out.push((rel, size, modified));
if out.len() >= cap {
return Ok(out);
}
}
}
}
Ok(out)
}
}
impl RepositoryBackendTrait for RawBackend {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Raw
}
fn is_writable(&self) -> bool {
true
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
self.read_relpath(&Self::id_to_relpath(id))
.map(|o| o.map(traits::Bytes::from))
}
fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
self.write_relpath(&Self::id_to_relpath(id), data)
}
/// The streamed write: the blob goes to disk as it arrives, through one
/// bounded buffer, hashed on the way past. Same durability chain as `put`.
fn put_from_reader(
&self,
id: &ArtifactId,
body: &mut dyn std::io::Read,
_size_hint: Option<u64>,
) -> anyhow::Result<()> {
self.write_relpath_from(&Self::id_to_relpath(id), body)
}
/// The streamed read: the file is opened once and handed out `chunk_bytes`
/// at a time, so the first piece leaves before the last is read and a
/// door serving a 1 GiB blob holds one chunk of it.
fn fetch_chunks(
&self,
id: &ArtifactId,
chunk_bytes: usize,
) -> anyhow::Result<Option<traits::ChunkIter>> {
let path = safe_relpath(&self.base_dir, &Self::id_to_relpath(id))?;
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
Ok(Some(traits::backend::file_chunks(
file,
chunk_bytes.clamp(1, STREAM_BUF_BYTES),
)))
}
/// Everything: a real listing, streamed reads and writes off the disk,
/// a total path mapping (`raw` is path-addressed) and a verified delete.
/// No archive — the store is loose files.
fn caps(&self) -> traits::Caps {
traits::Caps {
archive: false,
group: false,
..traits::Caps::ALL
}
}
/// `PUT`/`POST` stream straight into `write_relpath_from`; every other
/// write verb carries no body raw reads, so it is answered by the whole-body
/// door with an empty one.
fn handle_http2_upload(
&self,
method: &str,
suburl: &str,
body: &mut dyn std::io::Read,
_size_hint: Option<u64>,
) -> anyhow::Result<Option<traits::HttpReply>> {
match method {
"PUT" | "POST" => {
let relpath = match self.http_relpath(suburl) {
Some(r) => r,
None => return Ok(Some((404, Vec::new(), b"Not found".to_vec()))),
};
self.write_relpath_from(&relpath, body)?;
crate::grpc::functional_status("holger-raw/http", "raw_put", true, &relpath);
let octet = vec![(
"Content-Type".to_string(),
"application/octet-stream".to_string(),
)];
Ok(Some((201, octet, Vec::new())))
}
_ => self.handle_http2_request(method, suburl, &[]).map(Some),
}
}
/// Reclaim one stored blob — the retention/GC executor's safe unlink. Read
/// the on-disk bytes, recompute their SHA-256, and remove **only** if it
/// matches `expected_content_id` (the digest the executor planned to drop); a
/// mismatch means the store changed under the plan → refuse. A missing file
/// is an error (nothing to reclaim). Mirrors the loose-file rust backend.
fn delete_artifact(&self, id: &ArtifactId, expected_content_id: &str) -> anyhow::Result<()> {
let relpath = Self::id_to_relpath(id);
let bytes = match self.read_relpath(&relpath)? {
Some(b) => b,
None => anyhow::bail!("raw artifact {relpath:?} not present — nothing to reclaim"),
};
let actual = sha256_hex(&bytes);
if actual != expected_content_id {
anyhow::bail!(
"refusing to delete raw {relpath:?}: on-disk content id {actual} != expected \
{expected_content_id} — the store changed under the retention plan"
);
}
self.remove_relpath(&relpath)?;
Ok(())
}
/// Every stored file, reported as `ArtifactId { namespace: None, name:
/// <relpath>, version: "" }` so the id round-trips back to the same path.
/// `name_filter` is a substring match on the relpath; `limit == 0` falls back
/// to a sane cap (1024). A missing `base_dir` yields `[]`, not an error.
fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
const DEFAULT_CAP: usize = 1024;
let cap = if limit == 0 { DEFAULT_CAP } else { limit };
let files = self.walk(name_filter, cap)?;
Ok(files
.into_iter()
.map(|(relpath, size, modified)| {
// `published_unix_ms`: the stored file's mtime, already in hand
// from the walk's own `metadata` call.
//
// `sha256`: a memo PEEK — the digest of this blob IF this process
// has already had its bytes in hand (a write, or a read it served)
// AND the `(len, mtime)` stamp still matches. Otherwise `None`.
//
// ⚠ The listing still does NOT hash. That is the invariant the
// old comment here was protecting and it is kept: a raw blob has
// no content-addressed name and no sidecar digest, and hashing
// the store on every listing is O(bytes of the repository). What
// changed is that "holger has never seen these bytes" and "holger
// refuses to say" stopped being the same answer — because a
// mirror asking "did this change?" against an upstream that
// SERVED it the bytes deserves the digest it already paid for.
// Without this, `unchanged` was unreachable against a raw repo
// and a downstream re-fetched the whole repository every tick.
let digest = self.peek_digest(&relpath, size, modified);
ArtifactEntry::unknown_provenance(
ArtifactId {
namespace: None,
name: relpath,
version: String::new(),
},
size as i64,
"application/octet-stream",
)
.with_published_system_time(modified)
.with_sha256(digest)
})
.collect())
}
/// Raw HTTP door: `GET`/`HEAD` reads, `PUT`/`POST` writes (the checksum-deploy
/// gate has already verified the body at the gateway), `DELETE` removes.
/// The path after the repo name is the blob address.
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
let relpath = match self.http_relpath(suburl) {
Some(r) => r,
None => return Ok((404, Vec::new(), b"Not found".to_vec())),
};
let octet = || {
vec![(
"Content-Type".to_string(),
"application/octet-stream".to_string(),
)]
};
match method {
// A HEAD asks the size: the file's metadata answers it (HOL-HEAD-1).
"HEAD" => match std::fs::metadata(safe_relpath(&self.base_dir, &relpath)?) {
Ok(m) if m.is_file() => {
let mut h = octet();
h.push(("Content-Length".to_string(), m.len().to_string()));
Ok((200, h, Vec::new()))
}
_ => Ok(traits::backend::head_of((
404,
Vec::new(),
b"Not found".to_vec(),
))),
},
"GET" => match self.read_relpath(&relpath) {
Ok(Some(bytes)) => {
let ok = true;
crate::grpc::functional_status("holger-raw/http", "raw_get", ok, &relpath);
Ok((200, octet(), bytes))
}
Ok(None) => {
crate::grpc::functional_status(
"holger-raw/http",
"raw_get_miss",
true,
&relpath,
);
Ok((404, Vec::new(), b"Not found".to_vec()))
}
Err(e) => Err(e),
},
"PUT" | "POST" => {
self.write_relpath(&relpath, body)?;
crate::grpc::functional_status("holger-raw/http", "raw_put", true, &relpath);
Ok((201, octet(), Vec::new()))
}
"DELETE" => {
let removed = self.remove_relpath(&relpath)?;
crate::grpc::functional_status("holger-raw/http", "raw_delete", removed, &relpath);
if removed {
Ok((204, Vec::new(), Vec::new()))
} else {
Ok((404, Vec::new(), b"Not found".to_vec()))
}
}
other => Ok((
405,
vec![("Allow".into(), "GET, HEAD, PUT, POST, DELETE".into())],
format!("method {other} not allowed on a raw repository").into_bytes(),
)),
}
}
/// A raw repo's HTTP path IS its coordinate: `/<repo>/<relpath>` maps back to
/// `ArtifactId { namespace: None, name: <relpath>, version: "" }` — exactly the
/// coordinate [`list`](RawBackend::list) reports and [`fetch`](RawBackend::fetch)
/// resolves. This lets the serve-time quarantine gate look up a raw blob's
/// properties before serving it over HTTP.
/// The mapping is total, so a `None` genuinely means "no artifact here".
fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
Ok(self.http_relpath(suburl).map(|relpath| ArtifactId {
namespace: None,
name: relpath,
version: String::new(),
}))
}
}
/// A stored blob read `chunk` bytes at a time — the iterator behind
/// [`RawBackend::fetch_chunks`]. One `read` per piece, one allocation per
/// piece (the piece itself, which the door hands to the wire and drops), no
/// buffer holding more than one piece.
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"holger-raw-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&p).unwrap();
p
}
fn id(ns: Option<&str>, name: &str, version: &str) -> ArtifactId {
ArtifactId {
namespace: ns.map(|s| s.to_string()),
name: name.into(),
version: version.into(),
}
}
#[test]
fn put_then_fetch_round_trip() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
let coord = id(Some("firmware"), "device.bin", "1.2.0");
repo.put(&coord, b"\x00binary payload\xff").unwrap();
// Byte-identical read-back.
assert_eq!(
repo.fetch(&coord).unwrap().as_deref(),
Some(b"\x00binary payload\xff".as_slice())
);
// The path is the join of the non-empty coordinate parts.
assert!(dir
.join("firmware")
.join("device.bin")
.join("1.2.0")
.is_file());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn fetch_missing_returns_none() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
assert_eq!(repo.fetch(&id(None, "ghost", "")).unwrap(), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn put_and_fetch_reject_path_traversal() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
// A `..` segment must be refused on BOTH the write and read paths — never
// silently written outside base_dir, never read from outside it.
let hostile = id(Some(".."), "etc", "passwd");
assert!(
repo.put(&hostile, b"pwn").is_err(),
"traversal put must be refused"
);
assert!(
repo.fetch(&hostile).is_err(),
"traversal fetch must be refused"
);
// An empty coordinate (no segments) is likewise rejected.
assert!(
repo.put(&id(None, "", ""), b"x").is_err(),
"empty path refused"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn http_put_get_delete_round_trip() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
// PUT stores.
let (s, _h, _b) = repo
.handle_http2_request("PUT", "/raw/reports/2024/q1.pdf", b"PDF-BYTES")
.unwrap();
assert_eq!(s, 201, "PUT creates");
// GET reads it back byte-identical.
let (s, _h, body) = repo
.handle_http2_request("GET", "/raw/reports/2024/q1.pdf", &[])
.unwrap();
assert_eq!(s, 200);
assert_eq!(body, b"PDF-BYTES");
// HEAD is 200 with no body.
let (s, _h, body) = repo
.handle_http2_request("HEAD", "/raw/reports/2024/q1.pdf", &[])
.unwrap();
assert_eq!(s, 200);
assert!(body.is_empty(), "HEAD carries no body");
// DELETE removes it; a second GET is 404.
let (s, _h, _b) = repo
.handle_http2_request("DELETE", "/raw/reports/2024/q1.pdf", &[])
.unwrap();
assert_eq!(s, 204);
let (s, _h, _b) = repo
.handle_http2_request("GET", "/raw/reports/2024/q1.pdf", &[])
.unwrap();
assert_eq!(s, 404, "gone after delete");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn http_get_missing_is_404_and_wrong_repo_is_404() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
let (s, _h, _b) = repo
.handle_http2_request("GET", "/raw/nope.bin", &[])
.unwrap();
assert_eq!(s, 404, "absent blob → 404");
// A path whose first segment is not this repo never resolves here.
let (s, _h, _b) = repo
.handle_http2_request("GET", "/other-repo/x.bin", &[])
.unwrap();
assert_eq!(s, 404, "not our route → 404");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn http_rejects_traversal_in_path() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
// `..` in the HTTP path must error out of safe_relpath, not escape.
assert!(
repo.handle_http2_request("PUT", "/raw/../escape.bin", b"pwn")
.is_err(),
"HTTP traversal must be refused"
);
std::fs::remove_dir_all(&dir).ok();
}
/// ★ **A raw listing stamps a time, and never hashes a blob it has not handled.**
///
/// The time is free — it comes out of the `metadata` call the walk already
/// makes for the size. The digest is NOT free: a raw blob has no
/// content-addressed name and no sidecar, so answering from disk would mean
/// reading every file in the store on every listing.
///
/// So this proves the half that must never change: a blob that appeared in
/// the store WITHOUT passing through holger's door is listed with no digest.
/// The listing did not open it, and it does not pretend otherwise. RED if
/// somebody "helpfully" makes the listing hash its store: this test would be
/// the only thing standing between a listing and O(bytes of the repository).
#[test]
fn a_raw_listing_never_hashes_a_blob_it_has_not_handled() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
let before = std::time::SystemTime::now() - std::time::Duration::from_secs(5);
// Straight onto the disk, behind holger's back — no put, no fetch.
std::fs::create_dir_all(dir.join("a/b")).unwrap();
std::fs::write(dir.join("a/b/one.bin"), b"111").unwrap();
let listed = repo.list(None, 0).unwrap();
assert_eq!(listed.len(), 1);
let e = &listed[0];
let ms = e
.published_unix_ms
.expect("a loose file has a modification time");
let floor = traits::system_time_to_unix_ms(before).expect("after the epoch");
assert!(
ms >= floor,
"the stamp is the file's own mtime, not something older: {ms} < {floor}"
);
assert!(ms > 0, "never a fabricated zero");
assert_eq!(
e.sha256, None,
"the listing must not have opened this file — a digest here means the walk hashed \
the store, which is O(bytes of the whole repository)"
);
std::fs::remove_dir_all(&dir).ok();
}
/// ★ **A blob holger has already had in its hands IS listed with its digest.**
///
/// This is the half that makes `unchanged` reachable. Before it, `raw`
/// published a time and no digest, so a downstream mirror comparing a
/// listing against what it already held could never conclude "nothing
/// changed" — and re-fetched the entire repository on every tick. The
/// upstream had already paid for the hash (it wrote the bytes, or it served
/// them); it was simply throwing the answer away.
///
/// Both doors are proven, because a mirror's upstream reaches blobs through
/// both: the one it WROTE, and the one it only ever READ.
#[test]
fn a_blob_holger_has_handled_is_listed_with_the_digest_it_already_paid_for() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
// Door 1: written through holger.
repo.put(&id(Some("a/b"), "written.bin", ""), b"111")
.unwrap();
// Door 2: appeared behind holger's back, then SERVED by it once.
std::fs::write(dir.join("served.bin"), b"2222").unwrap();
let before_serving = repo.list(None, 0).unwrap();
let served_first = before_serving
.iter()
.find(|e| e.id.name == "served.bin")
.expect("listed");
assert_eq!(
served_first.sha256, None,
"not yet — holger has not touched these bytes"
);
repo.fetch(&id(None, "served.bin", ""))
.unwrap()
.expect("present");
let listed = repo.list(None, 0).unwrap();
let written = listed
.iter()
.find(|e| e.id.name == "a/b/written.bin")
.expect("listed");
let served = listed
.iter()
.find(|e| e.id.name == "served.bin")
.expect("listed");
assert_eq!(
written.sha256.as_deref(),
Some(sha256_hex(b"111").as_str()),
"the digest of the bytes the PUT wrote"
);
assert_eq!(
served.sha256.as_deref(),
Some(sha256_hex(b"2222").as_str()),
"the digest of the bytes the FETCH read"
);
std::fs::remove_dir_all(&dir).ok();
}
/// ★ **A memo whose bytes changed underneath it is not served.**
///
/// The memo is keyed by `(len, mtime)` precisely because a raw blob is
/// MUTABLE — this is the one way the pattern differs from `file-rust`, whose
/// crate files cannot change. An out-of-band overwrite must make the listing
/// fall back to the honest `None`, never answer with the digest of bytes
/// that are no longer there. A stale digest would be worse than no digest:
/// it would tell a mirror "unchanged" about a blob that DID change, and the
/// mirror would skip it forever.
#[test]
fn a_digest_whose_bytes_changed_underneath_it_is_not_served() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
repo.put(&id(None, "mutable.bin", ""), b"111").unwrap();
assert_eq!(
repo.list(None, 0).unwrap()[0].sha256.as_deref(),
Some(sha256_hex(b"111").as_str())
);
// Behind holger's back, a different length at the same path.
std::fs::write(dir.join("mutable.bin"), b"22222222").unwrap();
assert_eq!(
repo.list(None, 0).unwrap()[0].sha256,
None,
"the stamp no longer matches, so the memo must not answer — an honest unknown, \
never the digest of bytes that are gone"
);
// And one read restores an HONEST answer, for the new bytes.
repo.fetch(&id(None, "mutable.bin", ""))
.unwrap()
.expect("present");
assert_eq!(
repo.list(None, 0).unwrap()[0].sha256.as_deref(),
Some(sha256_hex(b"22222222").as_str())
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn list_enumerates_nested_and_round_trips_to_fetch() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
repo.put(&id(Some("a/b"), "one.bin", ""), b"111").unwrap();
repo.put(&id(None, "two.bin", ""), b"22").unwrap();
repo.put(&id(Some("a"), "three.txt", ""), b"3333").unwrap();
let listed = repo.list(None, 0).unwrap();
assert_eq!(listed.len(), 3, "all three nested blobs enumerated");
// Every listed id round-trips: fetch by the listed coordinate resolves to
// the same bytes (this is what retention/search/replication rely on).
for e in &listed {
let bytes = repo.fetch(&e.id).unwrap();
assert!(bytes.is_some(), "listed id {:?} must re-fetch", e.id.name);
assert_eq!(bytes.unwrap().len() as i64, e.size_bytes, "size matches");
}
// The nested blob's relpath is carried whole in `name`.
assert!(
listed.iter().any(|e| e.id.name == "a/b/one.bin"),
"nested relpath preserved in the listed coordinate"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn list_filters_by_substring_and_respects_limit() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
repo.put(&id(None, "alpha.bin", ""), b"a").unwrap();
repo.put(&id(None, "beta.bin", ""), b"b").unwrap();
repo.put(&id(None, "alpine.txt", ""), b"c").unwrap();
// Substring "alp" matches alpha + alpine, not beta.
let matched = repo.list(Some("alp"), 0).unwrap();
assert_eq!(matched.len(), 2, "substring filter selects alpha + alpine");
assert!(matched.iter().all(|e| e.id.name.contains("alp")));
// limit caps the count.
let capped = repo.list(None, 1).unwrap();
assert_eq!(capped.len(), 1, "limit=1 returns exactly one");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn delete_artifact_verifies_content_id_before_unlink() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
let coord = id(None, "keep.bin", "");
repo.put(&coord, b"real-bytes").unwrap();
let good = sha256_hex(b"real-bytes");
// RED-when-broken: a WRONG content id must NOT unlink (safe GC contract).
let wrong = sha256_hex(b"different");
assert!(
repo.delete_artifact(&coord, &wrong).is_err(),
"mismatched content id must refuse to delete"
);
assert!(
repo.fetch(&coord).unwrap().is_some(),
"blob survives a refused delete"
);
// The correct content id unlinks.
repo.delete_artifact(&coord, &good).unwrap();
assert_eq!(
repo.fetch(&coord).unwrap(),
None,
"blob gone after verified delete"
);
// Deleting an absent blob is an error (nothing to reclaim).
assert!(
repo.delete_artifact(&coord, &good).is_err(),
"absent → error"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A raw repository really enumerates (`list` walks the files), so it must
/// SAY so: `supports_listing` defaulted to false here, and a counter — or a
/// purge, which refuses to call a repository it cannot enumerate empty —
/// read a real listing as "cannot say".
#[test]
fn a_raw_repository_says_it_enumerates() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
assert!(repo.caps().listing, "raw overrides list, so it enumerates");
traits::backend::conformance::check(&repo).unwrap();
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn writable_and_raw_format() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
assert!(repo.is_writable(), "raw is a writable hosted repo");
assert_eq!(repo.format(), ArtifactFormat::Raw);
assert_eq!(repo.name(), "raw");
std::fs::remove_dir_all(&dir).ok();
}
/// **Every hostile segment shape is refused on every door.** `..` is only the
/// obvious one: an absolute path (a leading `/` = an empty first segment), an
/// empty segment from a doubled slash, a lone `.`, a Windows separator and an
/// embedded NUL must all be rejected — on `put`, on `fetch`, and on the safe
/// delete — and the store must be left completely empty afterwards.
#[test]
fn every_hostile_path_shape_is_refused_and_nothing_is_written() {
let dir = tempfile::tempdir().unwrap();
let repo = RawBackend::new("raw".into(), dir.path().to_path_buf());
let hostile = [
"..",
".",
"",
"/etc/passwd", // absolute → an empty leading segment
"a//b", // a doubled separator → an empty segment
"a/../../etc", // traversal below the root
"trailing/", // an empty trailing segment
"back\\slash", // a Windows separator inside a segment
"nul\0byte", // an embedded NUL
];
for path in hostile {
let coord = id(None, path, "");
assert!(
repo.put(&coord, b"pwn").is_err(),
"put must refuse {path:?}"
);
assert!(repo.fetch(&coord).is_err(), "fetch must refuse {path:?}");
assert!(
repo.delete_artifact(&coord, &sha256_hex(b"pwn")).is_err(),
"delete must refuse {path:?}"
);
}
assert_eq!(
std::fs::read_dir(dir.path()).unwrap().count(),
0,
"a refused path writes nothing at all"
);
assert!(repo.list(None, 0).unwrap().is_empty());
}
/// A zero-byte blob is a REAL blob: it stores, it lists, `fetch` returns empty
/// bytes (never `None`), HTTP serves it as a 200 with an empty body, and the
/// safe delete verifies the SHA-256 of nothing. RED-when-broken if emptiness is
/// ever collapsed into absence — an empty marker file would become unservable
/// and unreclaimable.
#[test]
fn a_zero_byte_blob_is_stored_served_and_reclaimed_like_any_other() {
let dir = tempfile::tempdir().unwrap();
let repo = RawBackend::new("raw".into(), dir.path().to_path_buf());
let coord = id(None, "marker", "");
repo.put(&coord, b"").unwrap();
assert_eq!(
repo.fetch(&coord).unwrap(),
Some(traits::Bytes::new()),
"empty ≠ absent"
);
let listed = repo.list(None, 0).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].size_bytes, 0);
let (status, _h, body) = repo
.handle_http2_request("GET", "/raw/marker", &[])
.unwrap();
assert_eq!(status, 200, "an empty blob is found, not a 404");
assert!(body.is_empty());
repo.delete_artifact(&coord, &sha256_hex(b"")).unwrap();
assert_eq!(repo.fetch(&coord).unwrap(), None, "and it reclaims cleanly");
}
/// Overwriting a blob replaces it WHOLLY — a shorter body must not leave the
/// tail of the longer one behind. That is what the temp-then-rename buys, and
/// only a shrinking overwrite can catch its absence.
#[test]
fn a_shorter_overwrite_leaves_no_tail_of_the_previous_blob() {
let dir = tempfile::tempdir().unwrap();
let repo = RawBackend::new("raw".into(), dir.path().to_path_buf());
let coord = id(Some("firmware"), "device.bin", "1.0.0");
repo.put(&coord, &[b'A'; 8192]).unwrap();
repo.put(&coord, b"v2").unwrap();
assert_eq!(
repo.fetch(&coord).unwrap().as_deref(),
Some(b"v2".as_slice()),
"the new bytes are the WHOLE file"
);
// Same through the HTTP door.
repo.handle_http2_request("PUT", "/raw/firmware/device.bin/1.0.0", b"v3")
.unwrap();
let (_s, _h, body) = repo
.handle_http2_request("GET", "/raw/firmware/device.bin/1.0.0", &[])
.unwrap();
assert_eq!(body, b"v3");
}
/// An in-flight staging file (`.<pid>.<seq>.tmp` — what a crash mid-`put`
/// leaves) is not a blob: it must not appear in a listing, because a listing is
/// what retention, replication and search act on. A file that merely starts
/// with a dot IS a blob and stays visible — the skip is narrow on purpose.
#[test]
fn a_leftover_staging_file_is_not_listed_as_a_blob() {
let dir = tempfile::tempdir().unwrap();
let repo = RawBackend::new("raw".into(), dir.path().to_path_buf());
repo.put(&id(None, "real.bin", ""), b"real").unwrap();
std::fs::write(dir.path().join(".4711.0.tmp"), b"half-written").unwrap();
std::fs::write(dir.path().join(".hidden"), b"a dotfile is still a blob").unwrap();
let names: Vec<String> = repo
.list(None, 0)
.unwrap()
.into_iter()
.map(|e| e.id.name)
.collect();
assert!(names.contains(&"real.bin".to_string()));
assert!(
names.contains(&".hidden".to_string()),
"only staging files are skipped: {names:?}"
);
assert!(
!names.iter().any(|n| n.ends_with(".tmp")),
"an in-flight staging file must never be listed as an artifact: {names:?}"
);
}
/// **The coordinate ↔ path bijection**, end to end: a blob written through the
/// HTTP door resolves to the coordinate `coordinate_for_path` reports, that
/// coordinate is the one `list` reports, and feeding it back to `fetch` returns
/// the same bytes. Retention, GC, search and replication all rely on exactly
/// this round-trip — if it breaks, they act on coordinates that resolve nowhere.
#[test]
fn an_http_path_a_listed_coordinate_and_a_fetch_all_name_the_same_blob() {
let dir = tempfile::tempdir().unwrap();
let repo = RawBackend::new("raw".into(), dir.path().to_path_buf());
repo.handle_http2_request("PUT", "/raw/reports/2024/q1.pdf", b"PDF")
.unwrap();
let from_path = repo
.coordinate_for_path("/raw/reports/2024/q1.pdf")
.unwrap()
.expect("our own route resolves to a coordinate");
assert_eq!(from_path.name, "reports/2024/q1.pdf");
assert_eq!(from_path.namespace, None);
assert_eq!(from_path.version, "");
assert_eq!(
repo.fetch(&from_path).unwrap().as_deref(),
Some(b"PDF".as_slice())
);
let listed = repo.list(None, 0).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].id, from_path,
"the listed coordinate IS the path coordinate"
);
// A path that is not our route yields no coordinate at all.
assert_eq!(repo.coordinate_for_path("/other-repo/x.bin").unwrap(), None);
assert_eq!(
repo.coordinate_for_path("/raw").unwrap(),
None,
"the bare repo root is not a blob"
);
}
/// The query string is not part of a blob's address: `?sig=…` (a signed-URL
/// suffix a client may append) must resolve to the same stored blob, not to a
/// second one. And a unicode path round-trips through both doors.
#[test]
fn the_query_string_is_not_part_of_the_address_and_unicode_paths_round_trip() {
let dir = tempfile::tempdir().unwrap();
let repo = RawBackend::new("raw".into(), dir.path().to_path_buf());
repo.handle_http2_request("PUT", "/raw/räksmörgås/åäö.bin?sig=abc", b"unicode")
.unwrap();
let (status, _h, body) = repo
.handle_http2_request("GET", "/raw/räksmörgås/åäö.bin", &[])
.unwrap();
assert_eq!(
status, 200,
"the query string was not stored as part of the name"
);
assert_eq!(body, b"unicode");
// And the same blob answers when the query IS present.
let (status, _h, body) = repo
.handle_http2_request("GET", "/raw/räksmörgås/åäö.bin?sig=other", &[])
.unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"unicode");
let listed = repo.list(None, 0).unwrap();
assert_eq!(listed.len(), 1, "one blob, not one per query string");
assert_eq!(listed[0].id.name, "räksmörgås/åäö.bin");
}
#[test]
fn method_not_allowed_for_unknown_verb() {
let dir = tmpdir();
let repo = RawBackend::new("raw".into(), dir.clone());
let (s, headers, _b) = repo
.handle_http2_request("PATCH", "/raw/x.bin", &[])
.unwrap();
assert_eq!(s, 405, "PATCH is not a raw verb");
assert!(
headers.iter().any(|(k, _)| k == "Allow"),
"405 advertises the allowed verbs"
);
std::fs::remove_dir_all(&dir).ok();
}
}