389 lines
13 KiB
Rust
389 lines
13 KiB
Rust
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<CirMaterializeSummary, CirMaterializeError> {
|
|
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::<std::collections::BTreeSet<_>>();
|
|
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<PathBuf, CirMaterializeError> {
|
|
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::<Vec<_>>();
|
|
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<PathBuf, CirMaterializeError> {
|
|
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::<Vec<_>>();
|
|
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<std::collections::BTreeSet<String>, 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();
|
|
}
|
|
}
|