use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use yah_object_store::ObjectStore;
use crate::{blob_key, manifest_key, BundleError, BundleHash, BundleManifest};
const MANIFEST_FILENAME: &str = "manifest.toml";
const ACCESS_MARKER: &str = ".accessed";
#[derive(Debug, Clone)]
pub struct PublishReport {
pub digest: BundleHash,
pub uploaded: Vec<String>,
pub skipped: Vec<String>,
pub manifest_uploaded: bool,
}
pub fn publish_bundle(
store: &dyn ObjectStore,
bundle_dir: &Path,
) -> Result<PublishReport, BundleError> {
let manifest_path = bundle_dir.join(MANIFEST_FILENAME);
let manifest_text = fs::read_to_string(&manifest_path).map_err(io_ctx(&manifest_path))?;
let manifest = BundleManifest::from_toml_str(&manifest_text)?;
let digest = manifest.digest();
let mut uploaded = Vec::new();
let mut skipped = Vec::new();
for (path, hash) in &manifest.content {
let key = blob_key(hash);
if store.head(&key).map_err(store_err)? {
skipped.push(key);
continue;
}
let rel = checked_rel(path)?;
let file_path = bundle_dir.join(rel);
let bytes = fs::read(&file_path).map_err(io_ctx(&file_path))?;
let actual = BundleHash::of(&bytes);
if &actual != hash {
return Err(BundleError::HashMismatch {
what: path.clone(),
expected: hash.to_string(),
actual: actual.to_string(),
});
}
store.put(&key, bytes).map_err(store_err)?;
uploaded.push(key);
}
let mkey = manifest_key(&digest);
let manifest_uploaded = if store.head(&mkey).map_err(store_err)? {
false
} else {
let canonical = manifest.to_toml_string()?;
store.put(&mkey, canonical.into_bytes()).map_err(store_err)?;
true
};
Ok(PublishReport {
digest,
uploaded,
skipped,
manifest_uploaded,
})
}
pub fn materialize_bundle(
store: &dyn ObjectStore,
cache_dir: &Path,
digest: &BundleHash,
) -> Result<PathBuf, BundleError> {
let bundles_dir = cache_dir.join("bundles");
let dest = bundles_dir.join(digest.as_str());
let dest_manifest = dest.join(MANIFEST_FILENAME);
if dest_manifest.exists() {
return Ok(dest);
}
let mkey = manifest_key(digest);
let manifest_bytes = store
.get(&mkey)
.map_err(store_err)?
.ok_or_else(|| BundleError::MissingBlob {
key: mkey.clone(),
path: MANIFEST_FILENAME.to_string(),
})?;
let manifest_text = String::from_utf8(manifest_bytes)
.map_err(|e| BundleError::Manifest(format!("manifest object not utf-8: {e}")))?;
let manifest = BundleManifest::from_toml_str(&manifest_text)?;
let actual_digest = manifest.digest();
if &actual_digest != digest {
return Err(BundleError::HashMismatch {
what: mkey,
expected: digest.to_string(),
actual: actual_digest.to_string(),
});
}
let staging = bundles_dir.join(format!(".staging-{}", digest.as_str()));
if staging.exists() {
fs::remove_dir_all(&staging).map_err(io_ctx(&staging))?;
}
fs::create_dir_all(&staging).map_err(io_ctx(&staging))?;
for (path, hash) in &manifest.content {
let key = blob_key(hash);
let bytes = store
.get(&key)
.map_err(store_err)?
.ok_or_else(|| BundleError::MissingBlob {
key: key.clone(),
path: path.clone(),
})?;
let actual = BundleHash::of(&bytes);
if &actual != hash {
let _ = fs::remove_dir_all(&staging);
return Err(BundleError::HashMismatch {
what: path.clone(),
expected: hash.to_string(),
actual: actual.to_string(),
});
}
let rel = checked_rel(path)?;
let out = staging.join(rel);
if let Some(parent) = out.parent() {
fs::create_dir_all(parent).map_err(io_ctx(parent))?;
}
fs::write(&out, bytes).map_err(io_ctx(&out))?;
}
let staged_manifest = staging.join(MANIFEST_FILENAME);
fs::write(&staged_manifest, manifest_text.as_bytes()).map_err(io_ctx(&staged_manifest))?;
fs::create_dir_all(&bundles_dir).map_err(io_ctx(&bundles_dir))?;
if dest_manifest.exists() {
let _ = fs::remove_dir_all(&staging);
return Ok(dest);
}
fs::rename(&staging, &dest).map_err(io_ctx(&dest))?;
Ok(dest)
}
pub struct BundleCache {
root: PathBuf,
budget_bytes: u64,
}
impl BundleCache {
pub fn new(root: impl Into<PathBuf>, budget_bytes: u64) -> Self {
Self {
root: root.into(),
budget_bytes,
}
}
pub fn ensure(
&self,
store: &dyn ObjectStore,
digest: &BundleHash,
) -> Result<PathBuf, BundleError> {
let path = materialize_bundle(store, &self.root, digest)?;
touch_access(&path)?;
if self.budget_bytes > 0 {
self.evict_to_budget(digest)?;
}
Ok(path)
}
fn bundles_dir(&self) -> PathBuf {
self.root.join("bundles")
}
fn evict_to_budget(&self, keep: &BundleHash) -> Result<(), BundleError> {
let dir = self.bundles_dir();
let rd = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(io_ctx(&dir)(e)),
};
struct Entry {
path: PathBuf,
recency: u64,
size: u64,
}
let mut entries = Vec::new();
let mut total: u64 = 0;
for de in rd {
let de = de.map_err(io_ctx(&dir))?;
let name = de.file_name();
let name = name.to_string_lossy();
if name.starts_with(".staging-") || name == keep.as_str() {
if name == keep.as_str() {
total += dir_size(&de.path());
}
continue;
}
if !de.path().is_dir() {
continue;
}
let size = dir_size(&de.path());
total += size;
entries.push(Entry {
recency: access_recency(&de.path()),
path: de.path(),
size,
});
}
if total <= self.budget_bytes {
return Ok(());
}
entries.sort_by_key(|e| e.recency);
for e in entries {
if total <= self.budget_bytes {
break;
}
fs::remove_dir_all(&e.path).map_err(io_ctx(&e.path))?;
total = total.saturating_sub(e.size);
}
Ok(())
}
}
fn checked_rel(path: &str) -> Result<PathBuf, BundleError> {
if path.is_empty() {
return Err(BundleError::Manifest("empty content path".to_string()));
}
let p = Path::new(path);
for comp in p.components() {
match comp {
Component::Normal(_) => {}
_ => {
return Err(BundleError::Manifest(format!(
"unsafe content path {path:?} (must be relative, no '..' or absolute components)"
)));
}
}
}
Ok(p.to_path_buf())
}
fn store_err(e: yah_object_store::Error) -> BundleError {
BundleError::Io(e.to_string())
}
fn io_ctx(p: &Path) -> impl Fn(std::io::Error) -> BundleError + '_ {
move |e| BundleError::Io(format!("{}: {e}", p.display()))
}
fn touch_access(bundle_dir: &Path) -> Result<(), BundleError> {
let marker = bundle_dir.join(ACCESS_MARKER);
fs::write(&marker, b"1").map_err(io_ctx(&marker))
}
fn access_recency(bundle_dir: &Path) -> u64 {
let marker = bundle_dir.join(ACCESS_MARKER);
let mtime = fs::metadata(&marker)
.or_else(|_| fs::metadata(bundle_dir))
.and_then(|m| m.modified())
.ok();
mtime
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn dir_size(dir: &Path) -> u64 {
let mut total = 0;
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = fs::read_dir(&d) else { continue };
for de in rd.flatten() {
let Ok(ft) = de.file_type() else { continue };
if ft.is_dir() {
stack.push(de.path());
} else if ft.is_file() {
if let Ok(m) = de.metadata() {
total += m.len();
}
}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use tempfile::TempDir;
use yah_object_store::InMemoryObjectStore;
use crate::{BundleRuntime, SCHEMA_VERSION};
fn write_bundle(dir: &Path, files: &[(&str, &[u8])], runtime: BundleRuntime) -> BundleManifest {
let mut content = BTreeMap::new();
for (path, bytes) in files {
let full = dir.join(path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(&full, bytes).unwrap();
content.insert(path.to_string(), BundleHash::of(bytes));
}
let manifest = BundleManifest {
schema_version: SCHEMA_VERSION,
name: "yah-marketing".to_string(),
runtime,
content,
};
fs::write(dir.join(MANIFEST_FILENAME), manifest.to_toml_string().unwrap()).unwrap();
manifest
}
#[test]
fn publish_then_materialize_round_trips() {
let store = InMemoryObjectStore::new();
let src = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let manifest = write_bundle(
src.path(),
&[
("app/index.html", b"<html>home</html>"),
("app/dist/main.js", b"console.log(1)"),
],
BundleRuntime::Mesofact { version: "0.8.18".into() },
);
let report = publish_bundle(&store, src.path()).unwrap();
assert_eq!(report.digest, manifest.digest());
assert_eq!(report.uploaded.len(), 2);
assert!(report.skipped.is_empty());
assert!(report.manifest_uploaded);
let dest = materialize_bundle(&store, cache.path(), &manifest.digest()).unwrap();
assert_eq!(
fs::read(dest.join("app/index.html")).unwrap(),
b"<html>home</html>"
);
assert_eq!(
fs::read(dest.join("app/dist/main.js")).unwrap(),
b"console.log(1)"
);
assert!(dest.join(MANIFEST_FILENAME).exists());
}
#[test]
fn a_dot_directory_entry_publishes_and_materializes() {
let store = InMemoryObjectStore::new();
let src = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let beacon = "app/dist/html/.well-known/yah-publish.json";
let manifest = write_bundle(
src.path(),
&[
("app/dist/html/index.html", b"<html>home</html>"),
(beacon, br#"{"prefix":"bundle/yah-marketing","files":1}"#),
],
BundleRuntime::SelfContained,
);
let report = publish_bundle(&store, src.path()).unwrap();
assert_eq!(report.uploaded.len(), 2);
let dest = materialize_bundle(&store, cache.path(), &manifest.digest()).unwrap();
assert_eq!(
fs::read(dest.join(beacon)).unwrap(),
br#"{"prefix":"bundle/yah-marketing","files":1}"#
);
}
#[test]
fn republish_dedupes_blobs_and_skips_manifest() {
let store = InMemoryObjectStore::new();
let src = TempDir::new().unwrap();
write_bundle(
src.path(),
&[("app/index.html", b"same")],
BundleRuntime::SelfContained,
);
let first = publish_bundle(&store, src.path()).unwrap();
assert_eq!(first.uploaded.len(), 1);
assert!(first.manifest_uploaded);
let second = publish_bundle(&store, src.path()).unwrap();
assert!(second.uploaded.is_empty());
assert_eq!(second.skipped.len(), 1);
assert!(!second.manifest_uploaded);
assert_eq!(first.digest, second.digest);
}
#[test]
fn materialize_is_idempotent() {
let store = InMemoryObjectStore::new();
let src = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let m = write_bundle(src.path(), &[("app/x", b"y")], BundleRuntime::SelfContained);
publish_bundle(&store, src.path()).unwrap();
let a = materialize_bundle(&store, cache.path(), &m.digest()).unwrap();
let b = materialize_bundle(&store, cache.path(), &m.digest()).unwrap();
assert_eq!(a, b);
}
#[test]
fn publish_rejects_hash_mismatch() {
let store = InMemoryObjectStore::new();
let src = TempDir::new().unwrap();
let mut m = write_bundle(src.path(), &[("app/x", b"real")], BundleRuntime::SelfContained);
fs::write(src.path().join("app/x"), b"tampered").unwrap();
m.content.insert("app/x".to_string(), BundleHash::of(b"real"));
fs::write(src.path().join(MANIFEST_FILENAME), m.to_toml_string().unwrap()).unwrap();
let err = publish_bundle(&store, src.path()).unwrap_err();
assert!(matches!(err, BundleError::HashMismatch { .. }), "got {err:?}");
}
#[test]
fn materialize_reports_missing_blob() {
let store = InMemoryObjectStore::new();
let src = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let m = write_bundle(src.path(), &[("app/x", b"y")], BundleRuntime::SelfContained);
publish_bundle(&store, src.path()).unwrap();
store.delete(&blob_key(m.content.get("app/x").unwrap())).unwrap();
let err = materialize_bundle(&store, cache.path(), &m.digest()).unwrap_err();
assert!(matches!(err, BundleError::MissingBlob { .. }), "got {err:?}");
}
#[test]
fn unsafe_paths_are_rejected() {
assert!(checked_rel("../escape").is_err());
assert!(checked_rel("/abs").is_err());
assert!(checked_rel("a/../../b").is_err());
assert!(checked_rel("").is_err());
assert!(checked_rel("app/dist/main.js").is_ok());
}
#[test]
fn cache_evicts_lru_past_budget() {
let store = InMemoryObjectStore::new();
let cache_dir = TempDir::new().unwrap();
let mut digests = Vec::new();
for i in 0..3 {
let src = TempDir::new().unwrap();
let m = write_bundle(
src.path(),
&[("app/blob", vec![b'a' + i as u8; 1000].as_slice())],
BundleRuntime::SelfContained,
);
publish_bundle(&store, src.path()).unwrap();
digests.push(m.digest());
}
let cache = BundleCache::new(cache_dir.path(), 2500);
for d in &digests {
cache.ensure(&store, d).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
}
let base = cache_dir.path().join("bundles");
assert!(!base.join(digests[0].as_str()).exists(), "oldest should be evicted");
assert!(base.join(digests[1].as_str()).exists());
assert!(base.join(digests[2].as_str()).exists());
}
#[test]
fn cache_hit_bumps_recency_and_protects_from_eviction() {
let store = InMemoryObjectStore::new();
let cache_dir = TempDir::new().unwrap();
let mut digests = Vec::new();
for i in 0..3 {
let src = TempDir::new().unwrap();
let m = write_bundle(
src.path(),
&[("app/blob", vec![b'a' + i as u8; 1000].as_slice())],
BundleRuntime::SelfContained,
);
publish_bundle(&store, src.path()).unwrap();
digests.push(m.digest());
}
let cache = BundleCache::new(cache_dir.path(), 2500);
cache.ensure(&store, &digests[0]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
cache.ensure(&store, &digests[1]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
cache.ensure(&store, &digests[0]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
cache.ensure(&store, &digests[2]).unwrap();
let base = cache_dir.path().join("bundles");
assert!(base.join(digests[0].as_str()).exists(), "re-touched bundle survives");
assert!(!base.join(digests[1].as_str()).exists(), "LRU bundle evicted");
assert!(base.join(digests[2].as_str()).exists());
}
}