use std::path::{Path, PathBuf};
use thiserror::Error;
pub trait BundleSource: Send + Sync {
fn read_artifact(&self, name: &str) -> Result<Vec<u8>, BundleSourceError>;
fn list_artifacts(&self) -> Result<Vec<String>, BundleSourceError>;
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BundleSourceError {
#[error("bundle source I/O error: {0}")]
Io(String),
#[error("bundle member not found: {member}")]
NotFound {
member: String,
},
}
#[derive(Debug, Clone)]
pub struct LocalDirSource {
root: PathBuf,
}
impl LocalDirSource {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { root: path.into() }
}
fn collect_members(&self, dir: &Path, out: &mut Vec<String>) -> Result<(), BundleSourceError> {
let entries = std::fs::read_dir(dir).map_err(|e| BundleSourceError::Io(e.to_string()))?;
for entry in entries {
let entry = entry.map_err(|e| BundleSourceError::Io(e.to_string()))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|e| BundleSourceError::Io(e.to_string()))?;
if file_type.is_dir() {
self.collect_members(&path, out)?;
} else {
let rel = path.strip_prefix(&self.root).map_err(|_| {
BundleSourceError::Io(format!(
"member {} is not under bundle root {}",
path.display(),
self.root.display()
))
})?;
let normalized = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
out.push(normalized);
}
}
Ok(())
}
}
impl BundleSource for LocalDirSource {
fn read_artifact(&self, name: &str) -> Result<Vec<u8>, BundleSourceError> {
let path = self.root.join(name);
match std::fs::read(&path) {
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(BundleSourceError::NotFound {
member: name.to_string(),
})
},
Err(e) => Err(BundleSourceError::Io(e.to_string())),
}
}
fn list_artifacts(&self) -> Result<Vec<String>, BundleSourceError> {
let mut out = Vec::new();
self.collect_members(&self.root, &mut out)?;
out.sort();
Ok(out)
}
}
#[cfg(feature = "embedded")]
#[derive(Debug, Clone)]
pub struct EmbeddedSource {
dir: &'static include_dir::Dir<'static>,
}
#[cfg(feature = "embedded")]
impl EmbeddedSource {
pub fn new(dir: &'static include_dir::Dir<'static>) -> Self {
Self { dir }
}
fn collect(dir: &include_dir::Dir<'static>, out: &mut Vec<String>) {
for file in dir.files() {
out.push(file.path().to_string_lossy().replace('\\', "/"));
}
for sub in dir.dirs() {
Self::collect(sub, out);
}
}
}
#[cfg(feature = "embedded")]
impl BundleSource for EmbeddedSource {
fn read_artifact(&self, name: &str) -> Result<Vec<u8>, BundleSourceError> {
self.dir.get_file(name).map_or_else(
|| {
Err(BundleSourceError::NotFound {
member: name.to_string(),
})
},
|file| Ok(file.contents().to_vec()),
)
}
fn list_artifacts(&self) -> Result<Vec<String>, BundleSourceError> {
let mut out = Vec::new();
Self::collect(self.dir, &mut out);
out.sort();
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_send_sync<T: Send + Sync>() {}
#[test]
fn bundle_source_trait_object_is_send_sync() {
assert_send_sync::<Box<dyn BundleSource>>();
}
struct TempBundle {
path: PathBuf,
}
impl TempBundle {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("pwr-bundle-src-{tag}-{pid}-{n}"));
std::fs::create_dir_all(path.join("evidence")).unwrap();
std::fs::write(path.join("manifest.json"), b"{\"manifest\":true}").unwrap();
std::fs::write(path.join("BUNDLE.lock"), b"{\"lock\":1}").unwrap();
std::fs::write(path.join("evidence/changelog.json"), b"{\"changelog\":[]}").unwrap();
Self { path }
}
}
impl Drop for TempBundle {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn local_dir_source_reads_exact_bytes() {
let bundle = TempBundle::new("read");
let src = LocalDirSource::new(&bundle.path);
assert_eq!(
src.read_artifact("manifest.json").unwrap(),
b"{\"manifest\":true}"
);
assert_eq!(
src.read_artifact("evidence/changelog.json").unwrap(),
b"{\"changelog\":[]}"
);
}
#[test]
fn local_dir_source_lists_sorted_relative_paths_including_nested() {
let bundle = TempBundle::new("list");
let src = LocalDirSource::new(&bundle.path);
let members = src.list_artifacts().unwrap();
assert_eq!(
members,
vec![
"BUNDLE.lock".to_string(),
"evidence/changelog.json".to_string(),
"manifest.json".to_string(),
],
"members are sorted and include the nested evidence path"
);
}
#[test]
fn local_dir_source_missing_member_returns_not_found_not_panic() {
let bundle = TempBundle::new("missing");
let src = LocalDirSource::new(&bundle.path);
match src.read_artifact("does_not_exist.json") {
Err(BundleSourceError::NotFound { member }) => {
assert_eq!(member, "does_not_exist.json");
},
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn not_found_display_names_the_member() {
let err = BundleSourceError::NotFound {
member: "layout.json".to_string(),
};
assert!(format!("{err}").contains("layout.json"));
}
#[cfg(feature = "embedded")]
#[test]
fn embedded_source_matches_local_dir_bytes() {
use include_dir::{include_dir, Dir};
static FIXTURE: Dir = include_dir!("$CARGO_MANIFEST_DIR/tests/fixtures/embedded_bundle");
let embedded = EmbeddedSource::new(&FIXTURE);
let manifest_root = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/embedded_bundle"
);
let local = LocalDirSource::new(manifest_root);
for member in ["manifest.json", "evidence/changelog.json"] {
assert_eq!(
embedded.read_artifact(member).unwrap(),
local.read_artifact(member).unwrap(),
"embedded and local-dir bytes must match for {member}"
);
}
assert_eq!(
embedded.list_artifacts().unwrap(),
local.list_artifacts().unwrap(),
"embedded and local-dir member sets must match"
);
}
}