286 lines
10 KiB
Rust
286 lines
10 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use crate::storage::{RepositoryViewEntry, RepositoryViewState};
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct CurrentRepoEntry {
|
|
pub current_hash: [u8; 32],
|
|
pub current_hash_hex: String,
|
|
pub repository_source: String,
|
|
pub object_type: Option<String>,
|
|
pub state: RepositoryViewState,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct CurrentRepoObject {
|
|
pub rsync_uri: String,
|
|
pub current_hash_hex: String,
|
|
pub repository_source: String,
|
|
pub object_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Default, Debug)]
|
|
pub struct CurrentRepoIndex {
|
|
by_uri: HashMap<String, CurrentRepoEntry>,
|
|
}
|
|
|
|
pub type CurrentRepoIndexHandle = Arc<Mutex<CurrentRepoIndex>>;
|
|
|
|
impl CurrentRepoIndex {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn shared() -> CurrentRepoIndexHandle {
|
|
Arc::new(Mutex::new(Self::new()))
|
|
}
|
|
|
|
pub fn get_by_uri(&self, rsync_uri: &str) -> Option<&CurrentRepoEntry> {
|
|
self.by_uri.get(rsync_uri)
|
|
}
|
|
|
|
pub fn list_scope_uris(&self, repository_source: &str) -> Vec<String> {
|
|
let mut out = self
|
|
.by_uri
|
|
.iter()
|
|
.filter_map(|(rsync_uri, entry)| {
|
|
(entry.repository_source == repository_source).then(|| rsync_uri.clone())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
pub fn active_uri_count(&self) -> usize {
|
|
self.by_uri.len()
|
|
}
|
|
|
|
pub fn scope_count(&self) -> usize {
|
|
self.by_uri
|
|
.values()
|
|
.map(|entry| entry.repository_source.as_str())
|
|
.collect::<HashSet<_>>()
|
|
.len()
|
|
}
|
|
|
|
pub fn snapshot_objects(&self) -> Vec<CurrentRepoObject> {
|
|
let mut out = self
|
|
.by_uri
|
|
.iter()
|
|
.map(|(rsync_uri, entry)| CurrentRepoObject {
|
|
rsync_uri: rsync_uri.clone(),
|
|
current_hash_hex: entry.current_hash_hex.clone(),
|
|
repository_source: entry.repository_source.clone(),
|
|
object_type: entry.object_type.clone(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.by_uri.clear();
|
|
}
|
|
|
|
pub fn apply_repository_view_entries(
|
|
&mut self,
|
|
entries: &[RepositoryViewEntry],
|
|
) -> Result<(), String> {
|
|
for entry in entries {
|
|
self.apply_repository_view_entry(entry)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_repository_view_entry(&mut self, entry: &RepositoryViewEntry) -> Result<(), String> {
|
|
entry.validate_internal().map_err(|e| e.to_string())?;
|
|
|
|
match entry.state {
|
|
RepositoryViewState::Present | RepositoryViewState::Replaced => {
|
|
let repository_source = entry.repository_source.clone().ok_or_else(|| {
|
|
format!(
|
|
"repository_view entry missing repository_source for current object {}",
|
|
entry.rsync_uri
|
|
)
|
|
})?;
|
|
let current_hash_hex = entry.current_hash.clone().ok_or_else(|| {
|
|
format!(
|
|
"repository_view entry missing current_hash for current object {}",
|
|
entry.rsync_uri
|
|
)
|
|
})?;
|
|
let current_hash = decode_sha256_hex_32(¤t_hash_hex)?;
|
|
self.by_uri.insert(
|
|
entry.rsync_uri.clone(),
|
|
CurrentRepoEntry {
|
|
current_hash,
|
|
current_hash_hex: current_hash_hex.to_ascii_lowercase(),
|
|
repository_source,
|
|
object_type: entry.object_type.clone(),
|
|
state: entry.state,
|
|
},
|
|
);
|
|
}
|
|
RepositoryViewState::Withdrawn => {
|
|
self.by_uri.remove(&entry.rsync_uri);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn decode_sha256_hex_32(value: &str) -> Result<[u8; 32], String> {
|
|
if value.len() != 64 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) {
|
|
return Err(format!("invalid sha256 hex: {value}"));
|
|
}
|
|
let mut out = [0u8; 32];
|
|
hex::decode_to_slice(value, &mut out).map_err(|e| format!("hex decode failed: {e}"))?;
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::CurrentRepoIndex;
|
|
use crate::storage::{RepositoryViewEntry, RepositoryViewState};
|
|
|
|
fn present(source: &str, uri: &str, hash: &str) -> RepositoryViewEntry {
|
|
RepositoryViewEntry {
|
|
rsync_uri: uri.to_string(),
|
|
current_hash: Some(hash.to_string()),
|
|
repository_source: Some(source.to_string()),
|
|
object_type: Some("roa".to_string()),
|
|
state: RepositoryViewState::Present,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn current_repo_index_tracks_present_and_withdrawn_entries() {
|
|
let mut index = CurrentRepoIndex::new();
|
|
let uri = "rsync://example.test/repo/a.roa";
|
|
let source = "rsync://example.test/repo/";
|
|
let hash = &"11".repeat(32);
|
|
index
|
|
.apply_repository_view_entries(&[present(source, uri, hash)])
|
|
.expect("apply present");
|
|
let got = index.get_by_uri(uri).expect("current entry");
|
|
assert_eq!(got.current_hash_hex, hash.to_string());
|
|
assert_eq!(index.list_scope_uris(source), vec![uri.to_string()]);
|
|
|
|
index
|
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
|
rsync_uri: uri.to_string(),
|
|
current_hash: Some(hash.to_string()),
|
|
repository_source: Some(source.to_string()),
|
|
object_type: Some("roa".to_string()),
|
|
state: RepositoryViewState::Withdrawn,
|
|
}])
|
|
.expect("apply withdrawn");
|
|
assert!(index.get_by_uri(uri).is_none());
|
|
assert!(index.list_scope_uris(source).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn current_repo_index_moves_uri_between_scopes() {
|
|
let mut index = CurrentRepoIndex::new();
|
|
let uri = "rsync://example.test/repo/a.roa";
|
|
let old_scope = "rsync://example.test/repo/";
|
|
let new_scope = "https://rrdp.example.test/notification.xml";
|
|
index
|
|
.apply_repository_view_entries(&[present(old_scope, uri, &"22".repeat(32))])
|
|
.expect("apply old scope");
|
|
index
|
|
.apply_repository_view_entries(&[present(new_scope, uri, &"33".repeat(32))])
|
|
.expect("apply new scope");
|
|
|
|
assert!(index.list_scope_uris(old_scope).is_empty());
|
|
assert_eq!(index.list_scope_uris(new_scope), vec![uri.to_string()]);
|
|
assert_eq!(
|
|
index.get_by_uri(uri).expect("entry").current_hash_hex,
|
|
"33".repeat(32)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_repo_index_snapshot_objects_and_counts_are_sorted() {
|
|
let handle = CurrentRepoIndex::shared();
|
|
let mut index = handle.lock().expect("lock index");
|
|
index
|
|
.apply_repository_view_entries(&[
|
|
present(
|
|
"rsync://example.test/repo-b/",
|
|
"rsync://example.test/repo-b/b.roa",
|
|
&"22".repeat(32),
|
|
),
|
|
present(
|
|
"rsync://example.test/repo-a/",
|
|
"rsync://example.test/repo-a/a.roa",
|
|
&"11".repeat(32),
|
|
),
|
|
])
|
|
.expect("apply present entries");
|
|
assert_eq!(index.active_uri_count(), 2);
|
|
assert_eq!(index.scope_count(), 2);
|
|
|
|
let snapshot = index.snapshot_objects();
|
|
assert_eq!(snapshot.len(), 2);
|
|
assert_eq!(snapshot[0].rsync_uri, "rsync://example.test/repo-a/a.roa");
|
|
assert_eq!(snapshot[1].rsync_uri, "rsync://example.test/repo-b/b.roa");
|
|
}
|
|
|
|
#[test]
|
|
fn current_repo_index_reports_missing_fields_and_invalid_hash() {
|
|
let mut index = CurrentRepoIndex::new();
|
|
let err = index
|
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
|
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
|
current_hash: Some("11".repeat(32)),
|
|
repository_source: None,
|
|
object_type: Some("roa".to_string()),
|
|
state: RepositoryViewState::Present,
|
|
}])
|
|
.expect_err("missing source should fail");
|
|
assert!(err.contains("missing repository_source"), "{err}");
|
|
|
|
let err = index
|
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
|
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
|
current_hash: Some("not-a-valid-sha256".to_string()),
|
|
repository_source: Some("rsync://example.test/repo/".to_string()),
|
|
object_type: Some("roa".to_string()),
|
|
state: RepositoryViewState::Present,
|
|
}])
|
|
.expect_err("invalid hash should fail");
|
|
assert!(err.contains("invalid"), "{err}");
|
|
|
|
index
|
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
|
rsync_uri: "rsync://example.test/repo/b.roa".to_string(),
|
|
current_hash: Some("22".repeat(32)),
|
|
repository_source: Some("rsync://example.test/repo/".to_string()),
|
|
object_type: Some("roa".to_string()),
|
|
state: RepositoryViewState::Present,
|
|
}])
|
|
.expect("valid entry");
|
|
let got = index.get_by_uri("rsync://example.test/repo/b.roa").unwrap();
|
|
assert_eq!(got.current_hash_hex, "22".repeat(32));
|
|
}
|
|
|
|
#[test]
|
|
fn current_repo_index_withdraw_unknown_uri_is_noop() {
|
|
let mut index = CurrentRepoIndex::new();
|
|
index
|
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
|
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
|
|
current_hash: None,
|
|
repository_source: Some("rsync://example.test/repo/".to_string()),
|
|
object_type: Some("roa".to_string()),
|
|
state: RepositoryViewState::Withdrawn,
|
|
}])
|
|
.expect("withdraw unknown should not fail");
|
|
assert_eq!(index.active_uri_count(), 0);
|
|
assert_eq!(index.scope_count(), 0);
|
|
}
|
|
}
|