pub mod cache;
pub mod manifest;
pub mod state;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use crate::config::HashingMode;
use crate::merge::MergedManifest;
#[derive(Debug, Clone)]
pub struct Rendered {
pub path: PathBuf,
pub hash: String,
}
pub fn materialize(m: &MergedManifest, cache_root: &Path) -> anyhow::Result<Rendered> {
let shape = cache::shape(&BTreeSet::new(), &BTreeSet::new());
materialize_with_mode(m, cache_root, HashingMode::default(), &shape)
}
pub fn materialize_with_mode(
m: &MergedManifest,
cache_root: &Path,
mode: HashingMode,
shape: &str,
) -> anyhow::Result<Rendered> {
let hash = cache::hash_manifest(m)?;
let folder = cache::folder_name(mode, shape, &hash);
let dest = cache_root.join(&folder);
match mode {
HashingMode::Loose | HashingMode::Normal => {
write_in_place(m, &dest)?;
return Ok(Rendered { path: dest, hash });
}
HashingMode::Strict if dest.exists() => {
return Ok(Rendered { path: dest, hash });
}
HashingMode::Strict => {}
}
std::fs::create_dir_all(cache_root)?;
let staging = cache_root.join(format!(
"{folder}.{pid}.{nanos}.tmp",
pid = std::process::id(),
nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir(&staging)?;
for (rel, abs) in &m.files {
if crate::paths::is_unsafe_join_target(rel.to_string_lossy().as_ref()) {
anyhow::bail!("path traversal in bundle file: {}", rel.display());
}
let out = staging.join(rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(abs, &out)?;
}
match std::fs::rename(&staging, &dest) {
Ok(()) => Ok(Rendered { path: dest, hash }),
Err(e) => {
if dest.exists() {
let _ = std::fs::remove_dir_all(&staging);
Ok(Rendered { path: dest, hash })
} else {
let _ = std::fs::remove_dir_all(&staging);
Err(e.into())
}
}
}
}
fn write_in_place(m: &MergedManifest, dest: &Path) -> anyhow::Result<()> {
if m.files.is_empty() {
return Ok(());
}
std::fs::create_dir_all(dest)?;
for (rel, abs) in &m.files {
if crate::paths::is_unsafe_join_target(rel.to_string_lossy().as_ref()) {
anyhow::bail!("path traversal in bundle file: {}", rel.display());
}
let out = dest.join(rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(abs, &out)?;
}
prune_empty_dirs(dest)?;
Ok(())
}
pub(crate) fn prune_empty_dirs(root: &Path) -> anyhow::Result<()> {
let mut dirs: Vec<PathBuf> = Vec::new();
collect_subdirs(root, &mut dirs)?;
dirs.reverse();
for dir in dirs {
let is_empty = match std::fs::read_dir(&dir) {
Ok(mut rd) => rd.next().is_none(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
tracing::warn!("prune_empty_dirs: could not read {}: {e}", dir.display());
continue;
}
};
if is_empty && let Err(e) = std::fs::remove_dir(&dir) {
tracing::warn!("prune_empty_dirs: could not remove {}: {e}", dir.display());
}
}
Ok(())
}
fn collect_subdirs(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(anyhow::anyhow!("reading directory {}: {e}", dir.display())),
};
for entry in rd {
let entry = entry.with_context(|| format!("reading entry in {}", dir.display()))?;
let path = entry.path();
let meta = entry
.metadata()
.with_context(|| format!("stat {}", path.display()))?;
if meta.is_dir() {
out.push(path.clone());
collect_subdirs(&path, out)?;
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::merge::MergedManifest;
use proptest::prelude::*;
use std::collections::BTreeMap;
#[test]
fn materialize_rejects_path_traversal_in_files() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let src = tmp.path().join("src.txt");
std::fs::write(&src, b"x").expect("write src");
let cache = tmp.path().join("cache");
let mut files = BTreeMap::new();
files.insert(PathBuf::from("../escape.txt"), src);
let m = MergedManifest {
files,
..Default::default()
};
let err = materialize(&m, &cache).expect_err("must reject traversal");
assert!(
err.to_string().contains("traversal"),
"unexpected error: {err}"
);
}
#[test]
fn materialize_rejects_absolute_path_in_files() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let src = tmp.path().join("src.txt");
std::fs::write(&src, b"x").expect("write src");
let cache = tmp.path().join("cache");
let mut files = BTreeMap::new();
files.insert(PathBuf::from("/etc/llmenv-escape.txt"), src);
let m = MergedManifest {
files,
..Default::default()
};
let err = materialize(&m, &cache).expect_err("must reject absolute path");
assert!(
err.to_string().contains("traversal"),
"unexpected error: {err}"
);
}
proptest! {
#[test]
fn prune_empty_dirs_never_removes_root(
dirs in proptest::collection::vec("[a-z]{1,6}", 0..8_usize)
) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path().join("out");
std::fs::create_dir_all(&root).expect("create root");
for d in &dirs {
std::fs::create_dir_all(root.join(d)).expect("create subdir");
}
prune_empty_dirs(&root).expect("prune");
prop_assert!(root.exists(), "root must survive prune");
}
}
proptest! {
#[test]
fn prune_empty_dirs_preserves_files(
dir in "[a-z]{1,6}",
filename in "[a-z]{1,6}"
) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path().join("out");
let subdir = root.join(&dir);
std::fs::create_dir_all(&subdir).expect("create subdir");
let file = subdir.join(&filename);
std::fs::write(&file, b"content").expect("write file");
prune_empty_dirs(&root).expect("prune");
prop_assert!(file.exists(), "file must survive prune");
prop_assert!(subdir.exists(), "non-empty dir must survive prune");
}
}
proptest! {
#[test]
fn prune_empty_dirs_is_idempotent(
dirs in proptest::collection::vec("[a-z]{1,6}", 0..6_usize)
) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path().join("out");
std::fs::create_dir_all(&root).expect("create root");
for d in &dirs {
std::fs::create_dir_all(root.join(d)).expect("create subdir");
}
prune_empty_dirs(&root).expect("first prune");
prune_empty_dirs(&root).expect("second prune");
prop_assert!(root.exists(), "root must still exist after second prune");
}
}
}