Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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));
    }
}