179 lines
6.2 KiB
Rust
179 lines
6.2 KiB
Rust
//! Content-addressed blob storage and archive extraction for ingest.
|
|
//!
|
|
//! Blobs are stored at `<base>/blobs/<sha[0:2]>/<sha>` and deduplicated by
|
|
//! digest; per-run working directories live under `<base>/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<String, AgentError> {
|
|
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<Vec<u8>, 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<PathBuf, AgentError> {
|
|
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: `<base>/work/<target>/<artifact>`.
|
|
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"
|
|
);
|
|
}
|
|
}
|