//! Content-addressed blob storage and archive extraction for ingest. //! //! Blobs are stored at `/blobs//` and deduplicated by //! digest; per-run working directories live under `/work/`. use std::fs::{self, File}; use std::io::{self, Read}; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; use crate::error::AgentError; /// Read buffer size for streaming hashes/copies (64 KiB). const BUF_LEN: usize = 64 * 1024; /// Stream-hash a file with SHA-256, returning the lowercase-hex digest and the /// byte length. Streams so large firmware images never load fully into memory. pub fn hash_file(path: &Path) -> Result<(String, u64), AgentError> { let mut file = File::open(path)?; let mut hasher = Sha256::new(); let mut buf = [0u8; BUF_LEN]; let mut total: u64 = 0; loop { let n = file.read(&mut buf)?; if n == 0 { break; } hasher.update(&buf[..n]); total += n as u64; } Ok((hex::encode(hasher.finalize()), total)) } /// Store raw bytes in the content-addressed blob store under `base`, returning /// the SHA-256 digest. Used to stash a small derived artifact (e.g. the extracted /// PLC program source) so a Werkbank runner can fetch it by hash. Idempotent. pub fn store_bytes(base: &Path, bytes: &[u8]) -> Result { let sha = hex::encode(Sha256::digest(bytes)); let dir = base.join("blobs").join(&sha[0..2]); fs::create_dir_all(&dir)?; let dest = dir.join(&sha); if !dest.exists() { fs::write(&dest, bytes)?; } Ok(sha) } /// Read a blob's bytes by its SHA-256 digest. Rejects a non-hex/wrong-length hash /// so a request can't traverse outside the blob store. pub fn read_blob(base: &Path, sha: &str) -> Result, AgentError> { if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(AgentError::Other(format!("invalid content hash '{sha}'"))); } let path = base.join("blobs").join(&sha[0..2]).join(sha); Ok(fs::read(path)?) } /// Copy `src` into the content-addressed blob store under `base`, returning the /// stored path. Idempotent: an already-present blob is not rewritten. pub fn store_file(base: &Path, src: &Path, sha: &str) -> Result { if sha.len() < 2 { return Err(AgentError::Other(format!("invalid content hash '{sha}'"))); } let dir = base.join("blobs").join(&sha[0..2]); fs::create_dir_all(&dir)?; let dest = dir.join(sha); if !dest.exists() { fs::copy(src, &dest)?; } Ok(dest) } /// Extract a zip archive into `dest` (created if needed). `enclosed_name` /// sanitizes each entry path, so this is safe against zip-slip traversal. pub fn extract_zip(archive: &Path, dest: &Path) -> Result<(), AgentError> { let file = File::open(archive)?; let mut zip = zip::ZipArchive::new(file).map_err(|e| AgentError::Other(format!("open zip: {e}")))?; fs::create_dir_all(dest)?; for i in 0..zip.len() { let mut entry = zip .by_index(i) .map_err(|e| AgentError::Other(format!("read zip entry: {e}")))?; // `enclosed_name` returns `None` for traversal-unsafe paths — skip them. let Some(rel) = entry.enclosed_name() else { continue; }; let out = dest.join(rel); if entry.is_dir() { fs::create_dir_all(&out)?; } else { if let Some(parent) = out.parent() { fs::create_dir_all(parent)?; } let mut outfile = File::create(&out)?; io::copy(&mut entry, &mut outfile)?; } } Ok(()) } /// The working directory for one artifact of a target: `/work//`. pub fn work_dir(base: &Path, target_id: &str, artifact_id: &str) -> PathBuf { base.join("work").join(target_id).join(artifact_id) } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; /// A unique scratch directory, removed on drop. struct Scratch(PathBuf); impl Scratch { fn new() -> Self { let p = std::env::temp_dir().join(format!("cs-ingest-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&p).expect("mkdir scratch"); Self(p) } fn path(&self) -> &Path { &self.0 } } impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[test] fn hash_is_stable_and_reports_size() { let dir = Scratch::new(); let f = dir.path().join("a.bin"); fs::write(&f, b"hello world").expect("write"); let (sha, size) = hash_file(&f).expect("hash"); assert_eq!(size, 11); // Known SHA-256 of "hello world". assert_eq!( sha, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" ); } #[test] fn store_is_content_addressed_and_idempotent() { let base = Scratch::new(); let src = base.path().join("src.bin"); fs::write(&src, b"payload").expect("write"); let (sha, _) = hash_file(&src).expect("hash"); let p1 = store_file(base.path(), &src, &sha).expect("store"); let p2 = store_file(base.path(), &src, &sha).expect("store again"); assert_eq!(p1, p2); assert!(p1.ends_with(&sha)); assert!(p1.starts_with(base.path().join("blobs").join(&sha[0..2]))); assert_eq!(fs::read(&p1).expect("read"), b"payload"); } #[test] fn extract_zip_writes_entries() { let base = Scratch::new(); let archive = base.path().join("a.zip"); { let file = File::create(&archive).expect("create"); let mut w = zip::ZipWriter::new(file); let opts: zip::write::SimpleFileOptions = Default::default(); w.start_file("dir/hello.txt", opts).expect("start"); io::Write::write_all(&mut w, b"hi").expect("write"); w.finish().expect("finish"); } let dest = base.path().join("out"); extract_zip(&archive, &dest).expect("extract"); assert_eq!( fs::read_to_string(dest.join("dir/hello.txt")).expect("read"), "hi" ); } }