use std::fs; use std::path::{Path, PathBuf}; use crate::cir::model::CanonicalInputRepresentation; #[derive(Debug, thiserror::Error)] pub enum CirMaterializeError { #[error("invalid rsync URI: {0}")] InvalidRsyncUri(String), #[error("rsync URI must reference a file object, got directory-like URI: {0}")] DirectoryLikeRsyncUri(String), #[error("create mirror root failed: {path}: {detail}")] CreateMirrorRoot { path: String, detail: String }, #[error("remove mirror root failed: {path}: {detail}")] RemoveMirrorRoot { path: String, detail: String }, #[error("create parent directory failed: {path}: {detail}")] CreateParent { path: String, detail: String }, #[error("remove existing target failed: {path}: {detail}")] RemoveExistingTarget { path: String, detail: String }, #[error("static object not found for sha256={sha256_hex}")] MissingStaticObject { sha256_hex: String }, #[error("link target failed: {src} -> {dst}: {detail}")] Link { src: String, dst: String, detail: String }, #[error("copy target failed: {src} -> {dst}: {detail}")] Copy { src: String, dst: String, detail: String }, #[error("mirror tree mismatch after materialize: {0}")] TreeMismatch(String), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CirMaterializeSummary { pub object_count: usize, pub linked_files: usize, pub copied_files: usize, } pub fn materialize_cir( cir: &CanonicalInputRepresentation, static_root: &Path, mirror_root: &Path, clean_rebuild: bool, ) -> Result { cir.validate() .map_err(CirMaterializeError::TreeMismatch)?; if clean_rebuild && mirror_root.exists() { fs::remove_dir_all(mirror_root).map_err(|e| CirMaterializeError::RemoveMirrorRoot { path: mirror_root.display().to_string(), detail: e.to_string(), })?; } fs::create_dir_all(mirror_root).map_err(|e| CirMaterializeError::CreateMirrorRoot { path: mirror_root.display().to_string(), detail: e.to_string(), })?; let mut linked_files = 0usize; let mut copied_files = 0usize; for object in &cir.objects { let sha256_hex = hex::encode(&object.sha256); let source = resolve_static_pool_file(static_root, &sha256_hex)?; let relative = mirror_relative_path_for_rsync_uri(&object.rsync_uri)?; let target = mirror_root.join(&relative); if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| CirMaterializeError::CreateParent { path: parent.display().to_string(), detail: e.to_string(), })?; } if target.exists() { fs::remove_file(&target).map_err(|e| CirMaterializeError::RemoveExistingTarget { path: target.display().to_string(), detail: e.to_string(), })?; } match fs::hard_link(&source, &target) { Ok(()) => linked_files += 1, Err(link_err) => { fs::copy(&source, &target).map_err(|copy_err| CirMaterializeError::Copy { src: source.display().to_string(), dst: target.display().to_string(), detail: format!("{copy_err}; original link error: {link_err}"), })?; copied_files += 1; } } } let actual = collect_materialized_uris(mirror_root)?; let expected = cir .objects .iter() .map(|item| item.rsync_uri.clone()) .collect::>(); if actual != expected { return Err(CirMaterializeError::TreeMismatch(format!( "expected {} files, got {} files", expected.len(), actual.len() ))); } Ok(CirMaterializeSummary { object_count: cir.objects.len(), linked_files, copied_files, }) } pub fn mirror_relative_path_for_rsync_uri(rsync_uri: &str) -> Result { let url = url::Url::parse(rsync_uri) .map_err(|_| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?; if url.scheme() != "rsync" { return Err(CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string())); } let host = url .host_str() .ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?; let segments = url .path_segments() .ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))? .collect::>(); if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() { return Err(CirMaterializeError::DirectoryLikeRsyncUri( rsync_uri.to_string(), )); } let mut path = PathBuf::from(host); for segment in segments { if !segment.is_empty() { path.push(segment); } } Ok(path) } pub fn resolve_static_pool_file( static_root: &Path, sha256_hex: &str, ) -> Result { if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { return Err(CirMaterializeError::MissingStaticObject { sha256_hex: sha256_hex.to_string(), }); } let prefix1 = &sha256_hex[0..2]; let prefix2 = &sha256_hex[2..4]; let entries = fs::read_dir(static_root) .map_err(|_| CirMaterializeError::MissingStaticObject { sha256_hex: sha256_hex.to_string(), })?; let mut dates = entries .filter_map(Result::ok) .filter(|entry| entry.path().is_dir()) .map(|entry| entry.path()) .collect::>(); dates.sort(); for date_dir in dates { let candidate = date_dir.join(prefix1).join(prefix2).join(sha256_hex); if candidate.is_file() { return Ok(candidate); } } Err(CirMaterializeError::MissingStaticObject { sha256_hex: sha256_hex.to_string(), }) } fn collect_materialized_uris( mirror_root: &Path, ) -> Result, CirMaterializeError> { let mut out = std::collections::BTreeSet::new(); let mut stack = vec![mirror_root.to_path_buf()]; while let Some(path) = stack.pop() { for entry in fs::read_dir(&path).map_err(|e| CirMaterializeError::CreateMirrorRoot { path: path.display().to_string(), detail: e.to_string(), })? { let entry = entry.map_err(|e| CirMaterializeError::CreateMirrorRoot { path: path.display().to_string(), detail: e.to_string(), })?; let path = entry.path(); if path.is_dir() { stack.push(path); } else { let rel = path .strip_prefix(mirror_root) .expect("materialized path under mirror root") .to_string_lossy() .replace('\\', "/"); let uri = format!("rsync://{rel}"); out.insert(uri); } } } Ok(out) } #[cfg(test)] mod tests { use super::{CirMaterializeError, materialize_cir, mirror_relative_path_for_rsync_uri, resolve_static_pool_file}; use crate::cir::model::{ CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, }; use std::path::{Path, PathBuf}; fn sample_time() -> time::OffsetDateTime { time::OffsetDateTime::parse( "2026-04-07T12:34:56Z", &time::format_description::well_known::Rfc3339, ) .unwrap() } fn sample_cir() -> CanonicalInputRepresentation { CanonicalInputRepresentation { version: CIR_VERSION_V1, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ CirObject { rsync_uri: "rsync://example.net/repo/a.cer".to_string(), sha256: hex::decode( "1111111111111111111111111111111111111111111111111111111111111111", ) .unwrap(), }, CirObject { rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(), sha256: hex::decode( "2222222222222222222222222222222222222222222222222222222222222222", ) .unwrap(), }, ], tals: vec![CirTal { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], } } #[test] fn mirror_relative_path_for_rsync_uri_maps_host_and_path() { let path = mirror_relative_path_for_rsync_uri("rsync://example.net/repo/nested/b.roa").unwrap(); assert_eq!(path, PathBuf::from("example.net").join("repo").join("nested").join("b.roa")); } #[test] fn resolve_static_pool_file_finds_hash_across_dates() { let td = tempfile::tempdir().unwrap(); let path = td .path() .join("20260407") .join("11") .join("11"); std::fs::create_dir_all(&path).unwrap(); let file = path.join("1111111111111111111111111111111111111111111111111111111111111111"); std::fs::write(&file, b"x").unwrap(); let resolved = resolve_static_pool_file( td.path(), "1111111111111111111111111111111111111111111111111111111111111111", ) .unwrap(); assert_eq!(resolved, file); } #[test] fn resolve_static_pool_file_rejects_invalid_hash_and_missing_hash() { let td = tempfile::tempdir().unwrap(); let err = resolve_static_pool_file(td.path(), "not-a-hash") .expect_err("invalid hash should fail"); assert!(matches!(err, CirMaterializeError::MissingStaticObject { .. })); let err = resolve_static_pool_file( td.path(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) .expect_err("missing hash should fail"); assert!(matches!(err, CirMaterializeError::MissingStaticObject { .. })); } #[test] fn mirror_relative_path_rejects_non_rsync_and_directory_like_uris() { let err = mirror_relative_path_for_rsync_uri("https://example.net/repo/a.roa") .expect_err("non-rsync uri must fail"); assert!(matches!(err, CirMaterializeError::InvalidRsyncUri(_))); let err = mirror_relative_path_for_rsync_uri("rsync://example.net/repo/") .expect_err("directory-like uri must fail"); assert!(matches!(err, CirMaterializeError::DirectoryLikeRsyncUri(_))); } #[test] fn materialize_clean_rebuild_creates_exact_tree_and_removes_stale_files() { let td = tempfile::tempdir().unwrap(); let static_root = td.path().join("static"); let mirror_root = td.path().join("mirror"); write_static( &static_root, "20260407", "1111111111111111111111111111111111111111111111111111111111111111", b"a", ); write_static( &static_root, "20260407", "2222222222222222222222222222222222222222222222222222222222222222", b"b", ); std::fs::create_dir_all(mirror_root.join("stale")).unwrap(); std::fs::write(mirror_root.join("stale/old.txt"), b"old").unwrap(); let summary = materialize_cir(&sample_cir(), &static_root, &mirror_root, true).unwrap(); assert_eq!(summary.object_count, 2); assert_eq!(std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(), b"a"); assert_eq!( std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(), b"b" ); assert!(!mirror_root.join("stale/old.txt").exists()); } #[test] fn materialize_fails_when_static_object_missing() { let td = tempfile::tempdir().unwrap(); let err = materialize_cir( &sample_cir(), td.path(), &td.path().join("mirror"), true, ) .expect_err("missing static object must fail"); assert!(matches!(err, CirMaterializeError::MissingStaticObject { .. })); } #[test] fn materialize_without_clean_rebuild_detects_stale_extra_files() { let td = tempfile::tempdir().unwrap(); let static_root = td.path().join("static"); let mirror_root = td.path().join("mirror"); write_static( &static_root, "20260407", "1111111111111111111111111111111111111111111111111111111111111111", b"a", ); write_static( &static_root, "20260407", "2222222222222222222222222222222222222222222222222222222222222222", b"b", ); std::fs::create_dir_all(mirror_root.join("extra")).unwrap(); std::fs::write(mirror_root.join("extra/stale.txt"), b"stale").unwrap(); let err = materialize_cir(&sample_cir(), &static_root, &mirror_root, false) .expect_err("stale extra files should fail exact tree check"); assert!(matches!(err, CirMaterializeError::TreeMismatch(_))); } fn write_static(root: &Path, date: &str, hash: &str, bytes: &[u8]) { let path = root.join(date).join(&hash[0..2]).join(&hash[2..4]); std::fs::create_dir_all(&path).unwrap(); std::fs::write(path.join(hash), bytes).unwrap(); } }