plugin/host/src/store.rs
//! The host's own [`BlobStore`]: a directory of files.
//!
//! This is the storage a *linked* handler runs on, and it is also what the wasm
//! host hands to a loaded module through the four `host_store_*` imports — so
//! both surfaces read and write the same bytes in the same place, which is what
//! lets the agreement guard compare them at all.
use std::path::{Component, Path, PathBuf};
use holger_plugin_abi::BlobStore;
/// A directory-backed blob store. Keys are handler-chosen store paths
/// (`tillsynia-20260706.znippy`), never absolute paths — a module never learns
/// where the root is.
#[derive(Debug, Clone)]
pub struct DirStore {
root: PathBuf,
}
impl DirStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
/// Resolve a key to a path **inside** the root, or refuse it.
///
/// A plugin is untrusted input: it chooses its own keys, and a `.wasm` from a
/// plugin directory is exactly the thing that might choose `../../etc/shadow`.
/// This walks the components rather than canonicalising, so it also refuses a
/// key for a file that does not exist yet (which `canonicalize` cannot check).
fn resolve(&self, key: &str) -> Result<PathBuf, String> {
if key.is_empty() {
return Err("blob key is empty".to_string());
}
let mut out = self.root.clone();
for comp in Path::new(key).components() {
match comp {
Component::Normal(part) => out.push(part),
Component::CurDir => {}
Component::ParentDir => {
return Err(format!("blob key {key:?} escapes the store root with `..`"))
}
Component::RootDir | Component::Prefix(_) => {
return Err(format!(
"blob key {key:?} is absolute; keys are store-relative"
))
}
}
}
if out == self.root {
return Err(format!(
"blob key {key:?} resolves to the store root itself"
));
}
Ok(out)
}
}
impl BlobStore for DirStore {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
let path = self.resolve(key)?;
match std::fs::read(&path) {
Ok(b) => Ok(Some(b)),
// A missing key is `None`. Anything else is a STORE failure and is
// propagated: flattening a permission error into "absent" would
// report a broken disk as an empty repository.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("read {}: {e}", path.display())),
}
}
fn put(&self, key: &str, data: &[u8]) -> Result<(), String> {
let path = self.resolve(key)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create {}: {e}", parent.display()))?;
}
std::fs::write(&path, data).map_err(|e| format!("write {}: {e}", path.display()))
}
fn list(&self, prefix: &str) -> Result<Vec<(String, u64)>, String> {
let mut out = Vec::new();
let rd = match std::fs::read_dir(&self.root) {
Ok(rd) => rd,
// A store root that does not exist yet is an empty store, not a
// failure — a repository can be configured before its first upload.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(format!("read_dir {}: {e}", self.root.display())),
};
for entry in rd {
let entry = entry.map_err(|e| format!("read_dir {}: {e}", self.root.display()))?;
let meta = entry
.metadata()
.map_err(|e| format!("stat {}: {e}", entry.path().display()))?;
if !meta.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(prefix) {
out.push((name, meta.len()));
}
}
out.sort();
Ok(out)
}
fn size(&self, key: &str) -> Result<Option<u64>, String> {
let path = self.resolve(key)?;
match std::fs::metadata(&path) {
Ok(m) => Ok(Some(m.len())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("stat {}: {e}", path.display())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("holger-dirstore-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn a_key_cannot_climb_out_of_the_root() {
let s = DirStore::new(tmp("escape"));
for bad in ["../secret", "a/../../secret", "/etc/shadow", ".."] {
let err = s.get(bad).unwrap_err();
assert!(
err.contains("escapes") || err.contains("absolute") || err.contains("root itself"),
"key {bad:?} was not refused; got {err}"
);
}
// And the write side refuses the same keys — a guard on read alone
// would leave the interesting direction open.
assert!(
s.put("../secret", b"x").is_err(),
"traversal accepted on put"
);
}
#[test]
fn absent_and_empty_are_different_answers() {
let root = tmp("absent");
let s = DirStore::new(&root);
std::fs::write(root.join("empty.bin"), b"").unwrap();
assert_eq!(
s.get("empty.bin").unwrap(),
Some(vec![]),
"an empty file read as absent"
);
assert_eq!(s.get("missing.bin").unwrap(), None);
assert_eq!(s.size("empty.bin").unwrap(), Some(0));
assert_eq!(s.size("missing.bin").unwrap(), None);
}
#[test]
fn a_missing_root_lists_empty_but_a_real_one_lists_its_files() {
let s = DirStore::new(std::env::temp_dir().join("holger-dirstore-does-not-exist-xyz"));
assert_eq!(s.list("").unwrap(), Vec::new());
let root = tmp("listing");
let s = DirStore::new(&root);
std::fs::write(root.join("b.znippy"), b"bb").unwrap();
std::fs::write(root.join("a.znippy"), b"a").unwrap();
std::fs::create_dir(root.join("subdir")).unwrap();
assert_eq!(
s.list("").unwrap(),
vec![("a.znippy".to_string(), 1), ("b.znippy".to_string(), 2)],
"listing is not sorted, or a directory leaked into it"
);
assert_eq!(s.list("b").unwrap(), vec![("b.znippy".to_string(), 2)]);
}
#[test]
fn a_round_trip_returns_the_same_bytes() {
let s = DirStore::new(tmp("roundtrip"));
let data = vec![0u8, 255, 10, b'"', b',', b':'];
s.put("x-1.znippy", &data).unwrap();
assert_eq!(s.get("x-1.znippy").unwrap(), Some(data));
}
}