feat(onboarding): artifact ingest + classifier + native tramiton + suite seams (#138)
This commit was merged in pull request #138.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
//! 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))
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user