rpki/src/cir/materialize.rs

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();
}
}