pub mod cache;
pub mod manifest;
pub mod state;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
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)?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::merge::MergedManifest;
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}"
);
}
}