use std::collections::HashSet; use std::path::Path; use rocksdb::{ ColumnFamily, ColumnFamilyDescriptor, DB, DBCompressionType, Direction, IteratorMode, Options, WriteBatch, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use sha2::Digest; use crate::data_model::rc::{AsResourceSet, IpResourceSet}; pub const CF_RAW_OBJECTS: &str = "raw_objects"; pub const CF_RRDP_STATE: &str = "rrdp_state"; pub const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index"; pub const CF_REPOSITORY_VIEW: &str = "repository_view"; pub const CF_RAW_BY_HASH: &str = "raw_by_hash"; pub const CF_VCIR: &str = "vcir"; pub const CF_AUDIT_RULE_INDEX: &str = "audit_rule_index"; pub const CF_RRDP_SOURCE: &str = "rrdp_source"; pub const CF_RRDP_SOURCE_MEMBER: &str = "rrdp_source_member"; pub const CF_RRDP_URI_OWNER: &str = "rrdp_uri_owner"; pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[ CF_RAW_OBJECTS, CF_RRDP_STATE, CF_RRDP_OBJECT_INDEX, CF_REPOSITORY_VIEW, CF_RAW_BY_HASH, CF_VCIR, CF_AUDIT_RULE_INDEX, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER, ]; const RRDP_OBJECT_INDEX_PREFIX: &[u8] = b"rrdp_obj:"; const REPOSITORY_VIEW_KEY_PREFIX: &str = "repo_view:"; const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:"; const VCIR_KEY_PREFIX: &str = "vcir:"; const AUDIT_ROA_RULE_KEY_PREFIX: &str = "audit:roa_rule:"; const AUDIT_ASPA_RULE_KEY_PREFIX: &str = "audit:aspa_rule:"; const RRDP_SOURCE_KEY_PREFIX: &str = "rrdp_source:"; const RRDP_SOURCE_MEMBER_KEY_PREFIX: &str = "rrdp_source_member:"; const RRDP_URI_OWNER_KEY_PREFIX: &str = "rrdp_uri_owner:"; fn cf_opts() -> Options { let mut opts = Options::default(); opts.set_compression_type(DBCompressionType::Lz4); enable_blobdb_if_supported(&mut opts); opts } pub fn column_family_descriptors() -> Vec { ALL_COLUMN_FAMILY_NAMES .iter() .map(|name| ColumnFamilyDescriptor::new(*name, cf_opts())) .collect() } #[derive(Debug, thiserror::Error)] pub enum StorageError { #[error("rocksdb error: {0}")] RocksDb(String), #[error("missing column family: {0}")] MissingColumnFamily(&'static str), #[error("cbor codec error for {entity}: {detail}")] Codec { entity: &'static str, detail: String, }, #[error("invalid {entity}: {detail}")] InvalidData { entity: &'static str, detail: String, }, } pub type StorageResult = Result; pub struct RocksStore { db: DB, } pub mod pack { pub use super::{PackFile, PackTime}; } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RrdpDeltaOp { Upsert { rsync_uri: String, bytes: Vec }, Delete { rsync_uri: String }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RepositoryViewState { Present, Withdrawn, Replaced, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositoryViewEntry { pub rsync_uri: String, pub current_hash: Option, pub repository_source: Option, pub object_type: Option, pub state: RepositoryViewState, } impl RepositoryViewEntry { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty("repository_view.rsync_uri", &self.rsync_uri)?; if let Some(source) = &self.repository_source { validate_non_empty("repository_view.repository_source", source)?; } match self.state { RepositoryViewState::Present | RepositoryViewState::Replaced => { let hash = self .current_hash .as_deref() .ok_or(StorageError::InvalidData { entity: "repository_view", detail: "current_hash is required when state is present or replaced" .to_string(), })?; validate_sha256_hex("repository_view.current_hash", hash)?; } RepositoryViewState::Withdrawn => { if let Some(hash) = &self.current_hash { validate_sha256_hex("repository_view.current_hash", hash)?; } } } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RawByHashEntry { pub sha256_hex: String, pub bytes: Vec, pub origin_uris: Vec, pub object_type: Option, pub encoding: Option, } impl RawByHashEntry { pub fn from_bytes(sha256_hex: impl Into, bytes: Vec) -> Self { Self { sha256_hex: sha256_hex.into(), bytes, origin_uris: Vec::new(), object_type: None, encoding: None, } } pub fn validate_internal(&self) -> StorageResult<()> { validate_sha256_hex("raw_by_hash.sha256_hex", &self.sha256_hex)?; if self.bytes.is_empty() { return Err(StorageError::InvalidData { entity: "raw_by_hash", detail: "bytes must not be empty".to_string(), }); } let computed = hex::encode(compute_sha256_32(&self.bytes)); if computed != self.sha256_hex.to_ascii_lowercase() { return Err(StorageError::InvalidData { entity: "raw_by_hash", detail: "sha256_hex does not match bytes".to_string(), }); } let mut seen = HashSet::with_capacity(self.origin_uris.len()); for uri in &self.origin_uris { validate_non_empty("raw_by_hash.origin_uris[]", uri)?; if !seen.insert(uri.as_str()) { return Err(StorageError::InvalidData { entity: "raw_by_hash", detail: format!("duplicate origin URI: {uri}"), }); } } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ValidatedManifestMeta { pub validated_manifest_number: Vec, pub validated_manifest_this_update: PackTime, pub validated_manifest_next_update: PackTime, } impl ValidatedManifestMeta { pub fn validate_internal(&self) -> StorageResult<()> { validate_manifest_number_be( "validated_manifest_meta.validated_manifest_number", &self.validated_manifest_number, )?; let this_update = parse_time( "validated_manifest_meta.validated_manifest_this_update", &self.validated_manifest_this_update, )?; let next_update = parse_time( "validated_manifest_meta.validated_manifest_next_update", &self.validated_manifest_next_update, )?; if next_update < this_update { return Err(StorageError::InvalidData { entity: "validated_manifest_meta", detail: "validated_manifest_next_update must be >= validated_manifest_this_update" .to_string(), }); } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirInstanceGate { pub manifest_next_update: PackTime, pub current_crl_next_update: PackTime, pub self_ca_not_after: PackTime, pub instance_effective_until: PackTime, } impl VcirInstanceGate { pub fn validate_internal(&self) -> StorageResult<()> { let manifest_next_update = parse_time( "vcir.instance_gate.manifest_next_update", &self.manifest_next_update, )?; let current_crl_next_update = parse_time( "vcir.instance_gate.current_crl_next_update", &self.current_crl_next_update, )?; let self_ca_not_after = parse_time( "vcir.instance_gate.self_ca_not_after", &self.self_ca_not_after, )?; let instance_effective_until = parse_time( "vcir.instance_gate.instance_effective_until", &self.instance_effective_until, )?; let expected = manifest_next_update .min(current_crl_next_update) .min(self_ca_not_after); if instance_effective_until != expected { return Err(StorageError::InvalidData { entity: "vcir.instance_gate", detail: "instance_effective_until must equal min(manifest_next_update, current_crl_next_update, self_ca_not_after)".to_string(), }); } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirChildEntry { pub child_manifest_rsync_uri: String, pub child_cert_rsync_uri: String, pub child_cert_hash: String, pub child_ski: String, pub child_rsync_base_uri: String, pub child_publication_point_rsync_uri: String, pub child_rrdp_notification_uri: Option, pub child_effective_ip_resources: Option, pub child_effective_as_resources: Option, pub accepted_at_validation_time: PackTime, } impl VcirChildEntry { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty( "vcir.child_entries[].child_manifest_rsync_uri", &self.child_manifest_rsync_uri, )?; validate_non_empty( "vcir.child_entries[].child_cert_rsync_uri", &self.child_cert_rsync_uri, )?; validate_sha256_hex( "vcir.child_entries[].child_cert_hash", &self.child_cert_hash, )?; validate_non_empty("vcir.child_entries[].child_ski", &self.child_ski)?; validate_non_empty( "vcir.child_entries[].child_rsync_base_uri", &self.child_rsync_base_uri, )?; validate_non_empty( "vcir.child_entries[].child_publication_point_rsync_uri", &self.child_publication_point_rsync_uri, )?; if let Some(uri) = &self.child_rrdp_notification_uri { validate_non_empty("vcir.child_entries[].child_rrdp_notification_uri", uri)?; } parse_time( "vcir.child_entries[].accepted_at_validation_time", &self.accepted_at_validation_time, )?; Ok(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum VcirOutputType { Vrp, Aspa, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirLocalOutput { pub output_id: String, pub output_type: VcirOutputType, pub item_effective_until: PackTime, pub source_object_uri: String, pub source_object_type: String, pub source_object_hash: String, pub source_ee_cert_hash: String, pub payload_json: String, pub rule_hash: String, pub validation_path_hint: Vec, } impl VcirLocalOutput { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty("vcir.local_outputs[].output_id", &self.output_id)?; parse_time( "vcir.local_outputs[].item_effective_until", &self.item_effective_until, )?; validate_non_empty( "vcir.local_outputs[].source_object_uri", &self.source_object_uri, )?; validate_non_empty( "vcir.local_outputs[].source_object_type", &self.source_object_type, )?; validate_sha256_hex( "vcir.local_outputs[].source_object_hash", &self.source_object_hash, )?; validate_sha256_hex( "vcir.local_outputs[].source_ee_cert_hash", &self.source_ee_cert_hash, )?; validate_sha256_hex("vcir.local_outputs[].rule_hash", &self.rule_hash)?; validate_non_empty("vcir.local_outputs[].payload_json", &self.payload_json)?; for hint in &self.validation_path_hint { validate_non_empty("vcir.local_outputs[].validation_path_hint[]", hint)?; } Ok(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum VcirArtifactRole { Manifest, CurrentCrl, ChildCaCert, SignedObject, EeCert, IssuerCert, Tal, TrustAnchorCert, Other, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum VcirArtifactKind { Cer, Crl, Mft, Roa, Aspa, Gbr, Tal, Other, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum VcirArtifactValidationStatus { Accepted, Rejected, WarningOnly, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirRelatedArtifact { pub artifact_role: VcirArtifactRole, pub artifact_kind: VcirArtifactKind, pub uri: Option, pub sha256: String, pub object_type: Option, pub validation_status: VcirArtifactValidationStatus, } impl VcirRelatedArtifact { pub fn validate_internal(&self) -> StorageResult<()> { if let Some(uri) = &self.uri { validate_non_empty("vcir.related_artifacts[].uri", uri)?; } validate_sha256_hex("vcir.related_artifacts[].sha256", &self.sha256)?; Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirSummary { pub local_vrp_count: u32, pub local_aspa_count: u32, pub child_count: u32, pub accepted_object_count: u32, pub rejected_object_count: u32, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirAuditSummary { pub failed_fetch_eligible: bool, pub last_failed_fetch_reason: Option, pub warning_count: u32, pub audit_flags: Vec, } impl VcirAuditSummary { pub fn validate_internal(&self) -> StorageResult<()> { if let Some(reason) = &self.last_failed_fetch_reason { validate_non_empty("vcir.audit_summary.last_failed_fetch_reason", reason)?; } for flag in &self.audit_flags { validate_non_empty("vcir.audit_summary.audit_flags[]", flag)?; } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ValidatedCaInstanceResult { pub manifest_rsync_uri: String, pub parent_manifest_rsync_uri: Option, pub tal_id: String, pub ca_subject_name: String, pub ca_ski: String, pub issuer_ski: String, pub last_successful_validation_time: PackTime, pub current_manifest_rsync_uri: String, pub current_crl_rsync_uri: String, pub validated_manifest_meta: ValidatedManifestMeta, pub instance_gate: VcirInstanceGate, pub child_entries: Vec, pub local_outputs: Vec, pub related_artifacts: Vec, pub summary: VcirSummary, pub audit_summary: VcirAuditSummary, } impl ValidatedCaInstanceResult { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty("vcir.manifest_rsync_uri", &self.manifest_rsync_uri)?; if let Some(parent_manifest_rsync_uri) = &self.parent_manifest_rsync_uri { validate_non_empty("vcir.parent_manifest_rsync_uri", parent_manifest_rsync_uri)?; } validate_non_empty("vcir.tal_id", &self.tal_id)?; validate_non_empty("vcir.ca_subject_name", &self.ca_subject_name)?; validate_non_empty("vcir.ca_ski", &self.ca_ski)?; validate_non_empty("vcir.issuer_ski", &self.issuer_ski)?; parse_time( "vcir.last_successful_validation_time", &self.last_successful_validation_time, )?; validate_non_empty( "vcir.current_manifest_rsync_uri", &self.current_manifest_rsync_uri, )?; validate_non_empty("vcir.current_crl_rsync_uri", &self.current_crl_rsync_uri)?; self.validated_manifest_meta.validate_internal()?; self.instance_gate.validate_internal()?; let expected_manifest_next = self .validated_manifest_meta .validated_manifest_next_update .parse() .map_err(|detail| StorageError::InvalidData { entity: "vcir", detail: format!( "validated_manifest_meta.validated_manifest_next_update invalid: {detail}" ), })?; let instance_manifest_next = self.instance_gate .manifest_next_update .parse() .map_err(|detail| StorageError::InvalidData { entity: "vcir", detail: format!("instance_gate.manifest_next_update invalid: {detail}"), })?; if expected_manifest_next != instance_manifest_next { return Err(StorageError::InvalidData { entity: "vcir", detail: "instance_gate.manifest_next_update must equal validated_manifest_meta.validated_manifest_next_update".to_string(), }); } let mut child_manifests = HashSet::with_capacity(self.child_entries.len()); for child in &self.child_entries { child.validate_internal()?; if !child_manifests.insert(child.child_manifest_rsync_uri.as_str()) { return Err(StorageError::InvalidData { entity: "vcir", detail: format!( "duplicate child_manifest_rsync_uri: {}", child.child_manifest_rsync_uri ), }); } } let mut output_ids = HashSet::with_capacity(self.local_outputs.len()); let mut vrp_count = 0u32; let mut aspa_count = 0u32; for output in &self.local_outputs { output.validate_internal()?; if !output_ids.insert(output.output_id.as_str()) { return Err(StorageError::InvalidData { entity: "vcir", detail: format!("duplicate output_id: {}", output.output_id), }); } match output.output_type { VcirOutputType::Vrp => vrp_count += 1, VcirOutputType::Aspa => aspa_count += 1, } } if self.summary.local_vrp_count != vrp_count { return Err(StorageError::InvalidData { entity: "vcir.summary", detail: format!( "local_vrp_count={} does not match local_outputs count {}", self.summary.local_vrp_count, vrp_count ), }); } if self.summary.local_aspa_count != aspa_count { return Err(StorageError::InvalidData { entity: "vcir.summary", detail: format!( "local_aspa_count={} does not match local_outputs count {}", self.summary.local_aspa_count, aspa_count ), }); } if self.summary.child_count != self.child_entries.len() as u32 { return Err(StorageError::InvalidData { entity: "vcir.summary", detail: format!( "child_count={} does not match child_entries length {}", self.summary.child_count, self.child_entries.len() ), }); } for artifact in &self.related_artifacts { artifact.validate_internal()?; } self.audit_summary.validate_internal()?; Ok(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuditRuleKind { Roa, Aspa, } impl AuditRuleKind { fn key_prefix(self) -> &'static str { match self { Self::Roa => AUDIT_ROA_RULE_KEY_PREFIX, Self::Aspa => AUDIT_ASPA_RULE_KEY_PREFIX, } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditRuleIndexEntry { pub kind: AuditRuleKind, pub rule_hash: String, pub manifest_rsync_uri: String, pub source_object_uri: String, pub source_object_hash: String, pub output_id: String, pub item_effective_until: PackTime, } impl AuditRuleIndexEntry { pub fn validate_internal(&self) -> StorageResult<()> { validate_sha256_hex("audit_rule_index.rule_hash", &self.rule_hash)?; validate_non_empty( "audit_rule_index.manifest_rsync_uri", &self.manifest_rsync_uri, )?; validate_non_empty( "audit_rule_index.source_object_uri", &self.source_object_uri, )?; validate_sha256_hex( "audit_rule_index.source_object_hash", &self.source_object_hash, )?; validate_non_empty("audit_rule_index.output_id", &self.output_id)?; parse_time( "audit_rule_index.item_effective_until", &self.item_effective_until, )?; Ok(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RrdpSourceSyncState { Empty, SnapshotOnly, DeltaReady, Error, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RrdpSourceRecord { pub notify_uri: String, pub last_session_id: Option, pub last_serial: Option, pub first_seen_at: PackTime, pub last_seen_at: PackTime, pub last_sync_at: Option, pub sync_state: RrdpSourceSyncState, pub last_snapshot_uri: Option, pub last_snapshot_hash: Option, pub last_error: Option, } impl RrdpSourceRecord { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty("rrdp_source.notify_uri", &self.notify_uri)?; if let Some(session_id) = &self.last_session_id { validate_non_empty("rrdp_source.last_session_id", session_id)?; } parse_time("rrdp_source.first_seen_at", &self.first_seen_at)?; parse_time("rrdp_source.last_seen_at", &self.last_seen_at)?; if let Some(last_sync_at) = &self.last_sync_at { parse_time("rrdp_source.last_sync_at", last_sync_at)?; } if let Some(last_snapshot_uri) = &self.last_snapshot_uri { validate_non_empty("rrdp_source.last_snapshot_uri", last_snapshot_uri)?; } if let Some(last_snapshot_hash) = &self.last_snapshot_hash { validate_sha256_hex("rrdp_source.last_snapshot_hash", last_snapshot_hash)?; } if let Some(last_error) = &self.last_error { validate_non_empty("rrdp_source.last_error", last_error)?; } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RrdpSourceMemberRecord { pub notify_uri: String, pub rsync_uri: String, pub current_hash: Option, pub object_type: Option, pub present: bool, pub last_confirmed_session_id: String, pub last_confirmed_serial: u64, pub last_changed_at: PackTime, } impl RrdpSourceMemberRecord { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty("rrdp_source_member.notify_uri", &self.notify_uri)?; validate_non_empty("rrdp_source_member.rsync_uri", &self.rsync_uri)?; validate_non_empty( "rrdp_source_member.last_confirmed_session_id", &self.last_confirmed_session_id, )?; if self.present { let hash = self .current_hash .as_deref() .ok_or(StorageError::InvalidData { entity: "rrdp_source_member", detail: "current_hash is required when present=true".to_string(), })?; validate_sha256_hex("rrdp_source_member.current_hash", hash)?; } else if let Some(hash) = &self.current_hash { validate_sha256_hex("rrdp_source_member.current_hash", hash)?; } parse_time("rrdp_source_member.last_changed_at", &self.last_changed_at)?; Ok(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RrdpUriOwnerState { Active, Conflict, Withdrawn, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RrdpUriOwnerRecord { pub rsync_uri: String, pub notify_uri: String, pub current_hash: Option, pub last_confirmed_session_id: String, pub last_confirmed_serial: u64, pub last_changed_at: PackTime, pub owner_state: RrdpUriOwnerState, } impl RrdpUriOwnerRecord { pub fn validate_internal(&self) -> StorageResult<()> { validate_non_empty("rrdp_uri_owner.rsync_uri", &self.rsync_uri)?; validate_non_empty("rrdp_uri_owner.notify_uri", &self.notify_uri)?; validate_non_empty( "rrdp_uri_owner.last_confirmed_session_id", &self.last_confirmed_session_id, )?; if let Some(hash) = &self.current_hash { validate_sha256_hex("rrdp_uri_owner.current_hash", hash)?; } parse_time("rrdp_uri_owner.last_changed_at", &self.last_changed_at)?; Ok(()) } } impl RocksStore { pub fn open(path: &Path) -> StorageResult { let mut base_opts = Options::default(); base_opts.create_if_missing(true); base_opts.create_missing_column_families(true); base_opts.set_compression_type(DBCompressionType::Lz4); enable_blobdb_if_supported(&mut base_opts); let db = DB::open_cf_descriptors(&base_opts, path, column_family_descriptors()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(Self { db }) } fn cf(&self, name: &'static str) -> StorageResult<&ColumnFamily> { self.db .cf_handle(name) .ok_or(StorageError::MissingColumnFamily(name)) } pub fn put_raw(&self, rsync_uri: &str, bytes: &[u8]) -> StorageResult<()> { let cf = self.cf(CF_RAW_OBJECTS)?; self.db .put_cf(cf, rsync_uri.as_bytes(), bytes) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn put_raw_batch(&self, objects: Vec<(String, Vec)>) -> StorageResult { if objects.is_empty() { return Ok(0); } let cf = self.cf(CF_RAW_OBJECTS)?; let mut batch = WriteBatch::default(); for (rsync_uri, bytes) in &objects { batch.put_cf(cf, rsync_uri.as_bytes(), bytes.as_slice()); } self.write_batch(batch)?; Ok(objects.len()) } pub fn get_raw(&self, rsync_uri: &str) -> StorageResult>> { let cf = self.cf(CF_RAW_OBJECTS)?; let v = self .db .get_cf(cf, rsync_uri.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(v) } pub fn delete_raw(&self, rsync_uri: &str) -> StorageResult<()> { let cf = self.cf(CF_RAW_OBJECTS)?; self.db .delete_cf(cf, rsync_uri.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn put_rrdp_state(&self, notification_uri: &str, bytes: &[u8]) -> StorageResult<()> { let cf = self.cf(CF_RRDP_STATE)?; self.db .put_cf(cf, notification_uri.as_bytes(), bytes) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_rrdp_state(&self, notification_uri: &str) -> StorageResult>> { let cf = self.cf(CF_RRDP_STATE)?; let v = self .db .get_cf(cf, notification_uri.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(v) } #[allow(dead_code)] pub fn delete_rrdp_state(&self, notification_uri: &str) -> StorageResult<()> { let cf = self.cf(CF_RRDP_STATE)?; self.db .delete_cf(cf, notification_uri.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } fn rrdp_object_index_key(notification_uri: &str, rsync_uri: &str) -> Vec { let mut out = Vec::with_capacity( RRDP_OBJECT_INDEX_PREFIX.len() + notification_uri.len() + 1 + rsync_uri.len(), ); out.extend_from_slice(RRDP_OBJECT_INDEX_PREFIX); out.extend_from_slice(notification_uri.as_bytes()); out.push(0); out.extend_from_slice(rsync_uri.as_bytes()); out } fn rrdp_object_index_prefix(notification_uri: &str) -> Vec { let mut out = Vec::with_capacity(RRDP_OBJECT_INDEX_PREFIX.len() + notification_uri.len() + 1); out.extend_from_slice(RRDP_OBJECT_INDEX_PREFIX); out.extend_from_slice(notification_uri.as_bytes()); out.push(0); out } #[allow(dead_code)] pub fn rrdp_object_index_contains( &self, notification_uri: &str, rsync_uri: &str, ) -> StorageResult { let cf = self.cf(CF_RRDP_OBJECT_INDEX)?; let k = Self::rrdp_object_index_key(notification_uri, rsync_uri); Ok(self .db .get_cf(cf, k) .map_err(|e| StorageError::RocksDb(e.to_string()))? .is_some()) } #[allow(dead_code)] pub fn rrdp_object_index_iter( &self, notification_uri: &str, ) -> StorageResult + '_> { let cf = self.cf(CF_RRDP_OBJECT_INDEX)?; let prefix = Self::rrdp_object_index_prefix(notification_uri); let prefix_len = prefix.len(); let mode = IteratorMode::From(prefix.as_slice(), Direction::Forward); Ok(self .db .iterator_cf(cf, mode) .take_while(move |res| match res { Ok((k, _v)) => k.starts_with(prefix.as_slice()), Err(_) => false, }) .filter_map(move |res| { let (k, _v) = res.ok()?; let rsync_part = k.get(prefix_len..)?; let s = std::str::from_utf8(rsync_part).ok()?; Some(s.to_string()) })) } #[allow(dead_code)] pub fn rrdp_object_index_clear(&self, notification_uri: &str) -> StorageResult { let cf = self.cf(CF_RRDP_OBJECT_INDEX)?; let prefix = Self::rrdp_object_index_prefix(notification_uri); let mode = IteratorMode::From(prefix.as_slice(), Direction::Forward); let keys: Vec> = self .db .iterator_cf(cf, mode) .take_while(|res| match res { Ok((k, _v)) => k.starts_with(prefix.as_slice()), Err(_) => false, }) .filter_map(|res| res.ok().map(|(k, _v)| k)) .collect(); if keys.is_empty() { return Ok(0); } let mut batch = WriteBatch::default(); for k in &keys { batch.delete_cf(cf, k); } self.write_batch(batch)?; Ok(keys.len()) } pub fn apply_rrdp_snapshot( &self, notification_uri: &str, published: &[(String, Vec)], ) -> StorageResult { let raw_cf = self.cf(CF_RAW_OBJECTS)?; let idx_cf = self.cf(CF_RRDP_OBJECT_INDEX)?; let mut new_set: HashSet<&str> = HashSet::with_capacity(published.len()); for (u, _b) in published { new_set.insert(u.as_str()); } let old_uris: Vec = self.rrdp_object_index_iter(notification_uri)?.collect(); let mut batch = WriteBatch::default(); for old in &old_uris { if !new_set.contains(old.as_str()) { batch.delete_cf(raw_cf, old.as_bytes()); let k = Self::rrdp_object_index_key(notification_uri, old.as_str()); batch.delete_cf(idx_cf, k); } } for (uri, bytes) in published { batch.put_cf(raw_cf, uri.as_bytes(), bytes.as_slice()); let k = Self::rrdp_object_index_key(notification_uri, uri.as_str()); batch.put_cf(idx_cf, k, b""); } self.write_batch(batch)?; Ok(published.len()) } pub fn apply_rrdp_delta( &self, notification_uri: &str, ops: &[RrdpDeltaOp], ) -> StorageResult { if ops.is_empty() { return Ok(0); } let raw_cf = self.cf(CF_RAW_OBJECTS)?; let idx_cf = self.cf(CF_RRDP_OBJECT_INDEX)?; let mut batch = WriteBatch::default(); for op in ops { match op { RrdpDeltaOp::Upsert { rsync_uri, bytes } => { batch.put_cf(raw_cf, rsync_uri.as_bytes(), bytes.as_slice()); let k = Self::rrdp_object_index_key(notification_uri, rsync_uri.as_str()); batch.put_cf(idx_cf, k, b""); } RrdpDeltaOp::Delete { rsync_uri } => { batch.delete_cf(raw_cf, rsync_uri.as_bytes()); let k = Self::rrdp_object_index_key(notification_uri, rsync_uri.as_str()); batch.delete_cf(idx_cf, k); } } } self.write_batch(batch)?; Ok(ops.len()) } pub fn put_repository_view_entry(&self, entry: &RepositoryViewEntry) -> StorageResult<()> { entry.validate_internal()?; let cf = self.cf(CF_REPOSITORY_VIEW)?; let key = repository_view_key(&entry.rsync_uri); let value = encode_cbor(entry, "repository_view")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_repository_view_entry( &self, rsync_uri: &str, ) -> StorageResult> { let cf = self.cf(CF_REPOSITORY_VIEW)?; let key = repository_view_key(rsync_uri); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let entry = decode_cbor::(&bytes, "repository_view")?; entry.validate_internal()?; Ok(Some(entry)) } pub fn delete_repository_view_entry(&self, rsync_uri: &str) -> StorageResult<()> { let cf = self.cf(CF_REPOSITORY_VIEW)?; let key = repository_view_key(rsync_uri); self.db .delete_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn list_repository_view_entries_with_prefix( &self, rsync_uri_prefix: &str, ) -> StorageResult> { let cf = self.cf(CF_REPOSITORY_VIEW)?; let prefix = repository_view_prefix(rsync_uri_prefix); let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward); self.db .iterator_cf(cf, mode) .take_while(|res| match res { Ok((key, _)) => key.starts_with(prefix.as_bytes()), Err(_) => false, }) .map(|res| { let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?; let entry = decode_cbor::(&value, "repository_view")?; entry.validate_internal()?; Ok(entry) }) .collect() } pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> { entry.validate_internal()?; let cf = self.cf(CF_RAW_BY_HASH)?; let key = raw_by_hash_key(&entry.sha256_hex); let value = encode_cbor(entry, "raw_by_hash")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult> { let cf = self.cf(CF_RAW_BY_HASH)?; let key = raw_by_hash_key(sha256_hex); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let entry = decode_cbor::(&bytes, "raw_by_hash")?; entry.validate_internal()?; Ok(Some(entry)) } pub fn put_vcir(&self, vcir: &ValidatedCaInstanceResult) -> StorageResult<()> { vcir.validate_internal()?; let cf = self.cf(CF_VCIR)?; let key = vcir_key(&vcir.manifest_rsync_uri); let value = encode_cbor(vcir, "vcir")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_vcir( &self, manifest_rsync_uri: &str, ) -> StorageResult> { let cf = self.cf(CF_VCIR)?; let key = vcir_key(manifest_rsync_uri); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let vcir = decode_cbor::(&bytes, "vcir")?; vcir.validate_internal()?; Ok(Some(vcir)) } pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> { let cf = self.cf(CF_VCIR)?; let key = vcir_key(manifest_rsync_uri); self.db .delete_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn put_audit_rule_index_entry(&self, entry: &AuditRuleIndexEntry) -> StorageResult<()> { entry.validate_internal()?; let cf = self.cf(CF_AUDIT_RULE_INDEX)?; let key = audit_rule_key(entry.kind, &entry.rule_hash); let value = encode_cbor(entry, "audit_rule_index")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_audit_rule_index_entry( &self, kind: AuditRuleKind, rule_hash: &str, ) -> StorageResult> { let cf = self.cf(CF_AUDIT_RULE_INDEX)?; let key = audit_rule_key(kind, rule_hash); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let entry = decode_cbor::(&bytes, "audit_rule_index")?; entry.validate_internal()?; Ok(Some(entry)) } pub fn delete_audit_rule_index_entry( &self, kind: AuditRuleKind, rule_hash: &str, ) -> StorageResult<()> { let cf = self.cf(CF_AUDIT_RULE_INDEX)?; let key = audit_rule_key(kind, rule_hash); self.db .delete_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn put_rrdp_source_record(&self, record: &RrdpSourceRecord) -> StorageResult<()> { record.validate_internal()?; let cf = self.cf(CF_RRDP_SOURCE)?; let key = rrdp_source_key(&record.notify_uri); let value = encode_cbor(record, "rrdp_source")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_rrdp_source_record( &self, notify_uri: &str, ) -> StorageResult> { let cf = self.cf(CF_RRDP_SOURCE)?; let key = rrdp_source_key(notify_uri); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let record = decode_cbor::(&bytes, "rrdp_source")?; record.validate_internal()?; Ok(Some(record)) } pub fn put_rrdp_source_member_record( &self, record: &RrdpSourceMemberRecord, ) -> StorageResult<()> { record.validate_internal()?; let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?; let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri); let value = encode_cbor(record, "rrdp_source_member")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_rrdp_source_member_record( &self, notify_uri: &str, rsync_uri: &str, ) -> StorageResult> { let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?; let key = rrdp_source_member_key(notify_uri, rsync_uri); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let record = decode_cbor::(&bytes, "rrdp_source_member")?; record.validate_internal()?; Ok(Some(record)) } pub fn list_rrdp_source_member_records( &self, notify_uri: &str, ) -> StorageResult> { let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?; let prefix = rrdp_source_member_prefix(notify_uri); let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward); self.db .iterator_cf(cf, mode) .take_while(|res| match res { Ok((key, _)) => key.starts_with(prefix.as_bytes()), Err(_) => false, }) .map(|res| { let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?; let record = decode_cbor::(&value, "rrdp_source_member")?; record.validate_internal()?; Ok(record) }) .collect() } pub fn put_rrdp_uri_owner_record(&self, record: &RrdpUriOwnerRecord) -> StorageResult<()> { record.validate_internal()?; let cf = self.cf(CF_RRDP_URI_OWNER)?; let key = rrdp_uri_owner_key(&record.rsync_uri); let value = encode_cbor(record, "rrdp_uri_owner")?; self.db .put_cf(cf, key.as_bytes(), value) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } pub fn get_rrdp_uri_owner_record( &self, rsync_uri: &str, ) -> StorageResult> { let cf = self.cf(CF_RRDP_URI_OWNER)?; let key = rrdp_uri_owner_key(rsync_uri); let Some(bytes) = self .db .get_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; let record = decode_cbor::(&bytes, "rrdp_uri_owner")?; record.validate_internal()?; Ok(Some(record)) } pub fn delete_rrdp_uri_owner_record(&self, rsync_uri: &str) -> StorageResult<()> { let cf = self.cf(CF_RRDP_URI_OWNER)?; let key = rrdp_uri_owner_key(rsync_uri); self.db .delete_cf(cf, key.as_bytes()) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } #[allow(dead_code)] pub fn raw_iter_prefix<'a>( &'a self, prefix: &'a [u8], ) -> StorageResult, Box<[u8]>)> + 'a> { let cf = self.cf(CF_RAW_OBJECTS)?; let mode = IteratorMode::From(prefix, Direction::Forward); Ok(self .db .iterator_cf(cf, mode) .take_while(move |res| match res { Ok((k, _v)) => k.starts_with(prefix), Err(_) => false, }) .filter_map(|res| res.ok())) } #[allow(dead_code)] pub fn raw_iter_all<'a>( &'a self, ) -> StorageResult, Box<[u8]>)> + 'a> { let cf = self.cf(CF_RAW_OBJECTS)?; let mode = IteratorMode::Start; Ok(self.db.iterator_cf(cf, mode).filter_map(|res| res.ok())) } #[allow(dead_code)] pub fn write_batch(&self, batch: WriteBatch) -> StorageResult<()> { self.db .write(batch) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(()) } } fn repository_view_key(rsync_uri: &str) -> String { format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri}") } fn repository_view_prefix(rsync_uri_prefix: &str) -> String { format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri_prefix}") } fn raw_by_hash_key(sha256_hex: &str) -> String { format!("{RAW_BY_HASH_KEY_PREFIX}{sha256_hex}") } fn vcir_key(manifest_rsync_uri: &str) -> String { format!("{VCIR_KEY_PREFIX}{manifest_rsync_uri}") } fn audit_rule_key(kind: AuditRuleKind, rule_hash: &str) -> String { format!("{}{rule_hash}", kind.key_prefix()) } fn rrdp_source_key(notify_uri: &str) -> String { format!("{RRDP_SOURCE_KEY_PREFIX}{notify_uri}") } fn rrdp_source_member_key(notify_uri: &str, rsync_uri: &str) -> String { format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:{rsync_uri}") } fn rrdp_source_member_prefix(notify_uri: &str) -> String { format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:") } fn rrdp_uri_owner_key(rsync_uri: &str) -> String { format!("{RRDP_URI_OWNER_KEY_PREFIX}{rsync_uri}") } fn encode_cbor(value: &T, entity: &'static str) -> StorageResult> { serde_cbor::to_vec(value).map_err(|e| StorageError::Codec { entity, detail: e.to_string(), }) } fn decode_cbor(bytes: &[u8], entity: &'static str) -> StorageResult { serde_cbor::from_slice(bytes).map_err(|e| StorageError::Codec { entity, detail: e.to_string(), }) } fn validate_non_empty(field: &'static str, value: &str) -> StorageResult<()> { if value.is_empty() { return Err(StorageError::InvalidData { entity: field, detail: "must not be empty".to_string(), }); } Ok(()) } fn validate_sha256_hex(field: &'static str, value: &str) -> StorageResult<()> { if value.len() != 64 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) { return Err(StorageError::InvalidData { entity: field, detail: "must be a 64-character lowercase or uppercase SHA-256 hex string".to_string(), }); } Ok(()) } fn validate_manifest_number_be(field: &'static str, value: &[u8]) -> StorageResult<()> { if value.is_empty() { return Err(StorageError::InvalidData { entity: field, detail: "must not be empty".to_string(), }); } if value.len() > 20 { return Err(StorageError::InvalidData { entity: field, detail: "must be at most 20 octets".to_string(), }); } if value.len() > 1 && value[0] == 0 { return Err(StorageError::InvalidData { entity: field, detail: "must be minimal big-endian without leading zeros".to_string(), }); } Ok(()) } fn parse_time(field: &'static str, value: &PackTime) -> StorageResult { value.parse().map_err(|detail| StorageError::InvalidData { entity: field, detail, }) } fn enable_blobdb_if_supported(opts: &mut Options) { #[allow(unused_mut)] let mut _enabled = false; #[allow(dead_code)] fn _set(opts: &mut Options) { opts.set_enable_blob_files(true); opts.set_min_blob_size(1024); } _set(opts); } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PackFile { pub rsync_uri: String, pub bytes: Vec, pub sha256: [u8; 32], } impl PackFile { pub fn new(rsync_uri: impl Into, bytes: Vec, sha256: [u8; 32]) -> Self { Self { rsync_uri: rsync_uri.into(), bytes, sha256, } } pub fn from_bytes_compute_sha256(rsync_uri: impl Into, bytes: Vec) -> Self { let sha256 = compute_sha256_32(&bytes); Self::new(rsync_uri, bytes, sha256) } pub fn compute_sha256(&self) -> [u8; 32] { compute_sha256_32(&self.bytes) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PackTime { pub rfc3339_utc: String, } impl PackTime { pub fn from_utc_offset_datetime(t: time::OffsetDateTime) -> Self { use time::format_description::well_known::Rfc3339; let utc = t.to_offset(time::UtcOffset::UTC); let s = utc.format(&Rfc3339).expect("format RFC 3339 UTC time"); Self { rfc3339_utc: s } } pub fn parse(&self) -> Result { use time::format_description::well_known::Rfc3339; time::OffsetDateTime::parse(&self.rfc3339_utc, &Rfc3339).map_err(|e| e.to_string()) } } fn compute_sha256_32(bytes: &[u8]) -> [u8; 32] { let digest = sha2::Sha256::digest(bytes); let mut out = [0u8; 32]; out.copy_from_slice(&digest); out } #[cfg(test)] mod tests { use super::*; fn pack_time(hour: i64) -> PackTime { PackTime::from_utc_offset_datetime( time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(hour), ) } fn sha256_hex(input: &[u8]) -> String { hex::encode(compute_sha256_32(input)) } fn sample_repository_view_entry(rsync_uri: &str, bytes: &[u8]) -> RepositoryViewEntry { RepositoryViewEntry { rsync_uri: rsync_uri.to_string(), current_hash: Some(sha256_hex(bytes)), repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), object_type: Some("cer".to_string()), state: RepositoryViewState::Present, } } fn sample_raw_by_hash_entry(bytes: Vec) -> RawByHashEntry { RawByHashEntry { sha256_hex: sha256_hex(&bytes), bytes, origin_uris: vec!["rsync://example.test/repo/object.cer".to_string()], object_type: Some("cer".to_string()), encoding: Some("der".to_string()), } } fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult { let roa_bytes = b"roa-object".to_vec(); let ee_bytes = b"ee-cert".to_vec(); let child_bytes = b"child-cert".to_vec(); ValidatedCaInstanceResult { manifest_rsync_uri: manifest_rsync_uri.to_string(), parent_manifest_rsync_uri: Some( "rsync://example.test/repo/parent/parent.mft".to_string(), ), tal_id: "apnic".to_string(), ca_subject_name: "CN=Example CA".to_string(), ca_ski: "00112233445566778899aabbccddeeff00112233".to_string(), issuer_ski: "ffeeddccbbaa99887766554433221100ffeeddcc".to_string(), last_successful_validation_time: pack_time(0), current_manifest_rsync_uri: manifest_rsync_uri.to_string(), current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(), validated_manifest_meta: ValidatedManifestMeta { validated_manifest_number: vec![3], validated_manifest_this_update: pack_time(0), validated_manifest_next_update: pack_time(24), }, instance_gate: VcirInstanceGate { manifest_next_update: pack_time(24), current_crl_next_update: pack_time(12), self_ca_not_after: pack_time(48), instance_effective_until: pack_time(12), }, child_entries: vec![VcirChildEntry { child_manifest_rsync_uri: "rsync://example.test/repo/child/child.mft".to_string(), child_cert_rsync_uri: "rsync://example.test/repo/child/child.cer".to_string(), child_cert_hash: sha256_hex(&child_bytes), child_ski: "1234567890abcdef1234567890abcdef12345678".to_string(), child_rsync_base_uri: "rsync://example.test/repo/child/".to_string(), child_publication_point_rsync_uri: "rsync://example.test/repo/child/".to_string(), child_rrdp_notification_uri: Some( "https://example.test/child-notify.xml".to_string(), ), child_effective_ip_resources: None, child_effective_as_resources: None, accepted_at_validation_time: pack_time(0), }], local_outputs: vec![ VcirLocalOutput { output_id: "vrp-1".to_string(), output_type: VcirOutputType::Vrp, item_effective_until: pack_time(12), source_object_uri: "rsync://example.test/repo/object.roa".to_string(), source_object_type: "roa".to_string(), source_object_hash: sha256_hex(&roa_bytes), source_ee_cert_hash: sha256_hex(&ee_bytes), payload_json: r#"{"asn":64496,"prefix":"203.0.113.0/24"}"#.to_string(), rule_hash: sha256_hex(b"vrp-rule-1"), validation_path_hint: vec![manifest_rsync_uri.to_string()], }, VcirLocalOutput { output_id: "aspa-1".to_string(), output_type: VcirOutputType::Aspa, item_effective_until: pack_time(10), source_object_uri: "rsync://example.test/repo/object.asa".to_string(), source_object_type: "aspa".to_string(), source_object_hash: sha256_hex(b"aspa-object"), source_ee_cert_hash: sha256_hex(b"aspa-ee-cert"), payload_json: r#"{"customer_as":64496,"providers":[64497]}"#.to_string(), rule_hash: sha256_hex(b"aspa-rule-1"), validation_path_hint: vec![manifest_rsync_uri.to_string()], }, ], related_artifacts: vec![ VcirRelatedArtifact { artifact_role: VcirArtifactRole::Manifest, artifact_kind: VcirArtifactKind::Mft, uri: Some(manifest_rsync_uri.to_string()), sha256: sha256_hex(b"manifest-object"), object_type: Some("mft".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, }, VcirRelatedArtifact { artifact_role: VcirArtifactRole::CurrentCrl, artifact_kind: VcirArtifactKind::Crl, uri: Some("rsync://example.test/repo/current.crl".to_string()), sha256: sha256_hex(b"current-crl"), object_type: Some("crl".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, }, ], summary: VcirSummary { local_vrp_count: 1, local_aspa_count: 1, child_count: 1, accepted_object_count: 4, rejected_object_count: 0, }, audit_summary: VcirAuditSummary { failed_fetch_eligible: true, last_failed_fetch_reason: None, warning_count: 0, audit_flags: vec!["validated-fresh".to_string()], }, } } fn sample_audit_rule_entry(kind: AuditRuleKind) -> AuditRuleIndexEntry { AuditRuleIndexEntry { kind, rule_hash: sha256_hex(match kind { AuditRuleKind::Roa => b"roa-index-rule", AuditRuleKind::Aspa => b"aspa-index-rule", }), manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), source_object_uri: match kind { AuditRuleKind::Roa => "rsync://example.test/repo/object.roa".to_string(), AuditRuleKind::Aspa => "rsync://example.test/repo/object.asa".to_string(), }, source_object_hash: sha256_hex(match kind { AuditRuleKind::Roa => b"roa-object", AuditRuleKind::Aspa => b"aspa-object", }), output_id: match kind { AuditRuleKind::Roa => "vrp-1".to_string(), AuditRuleKind::Aspa => "aspa-1".to_string(), }, item_effective_until: pack_time(12), } } fn sample_rrdp_source_record(notify_uri: &str) -> RrdpSourceRecord { RrdpSourceRecord { notify_uri: notify_uri.to_string(), last_session_id: Some("session-1".to_string()), last_serial: Some(42), first_seen_at: pack_time(0), last_seen_at: pack_time(1), last_sync_at: Some(pack_time(1)), sync_state: RrdpSourceSyncState::DeltaReady, last_snapshot_uri: Some("https://rrdp.example.test/snapshot.xml".to_string()), last_snapshot_hash: Some(sha256_hex(b"snapshot-bytes")), last_error: None, } } fn sample_rrdp_source_member_record( notify_uri: &str, rsync_uri: &str, serial: u64, ) -> RrdpSourceMemberRecord { RrdpSourceMemberRecord { notify_uri: notify_uri.to_string(), rsync_uri: rsync_uri.to_string(), current_hash: Some(sha256_hex(rsync_uri.as_bytes())), object_type: Some("cer".to_string()), present: true, last_confirmed_session_id: "session-1".to_string(), last_confirmed_serial: serial, last_changed_at: pack_time(serial as i64), } } fn sample_rrdp_uri_owner_record(notify_uri: &str, rsync_uri: &str) -> RrdpUriOwnerRecord { RrdpUriOwnerRecord { rsync_uri: rsync_uri.to_string(), notify_uri: notify_uri.to_string(), current_hash: Some(sha256_hex(rsync_uri.as_bytes())), last_confirmed_session_id: "session-1".to_string(), last_confirmed_serial: 7, last_changed_at: pack_time(7), owner_state: RrdpUriOwnerState::Active, } } #[test] fn rrdp_object_index_and_snapshot_delta_helpers_work_end_to_end() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let notification_uri = "https://rrdp.example.test/notification.xml"; let u1 = "rsync://rpki.example.test/repo/obj1.cer".to_string(); let u2 = "rsync://rpki.example.test/repo/obj2.mft".to_string(); assert_eq!( store .rrdp_object_index_clear(notification_uri) .expect("clear empty"), 0 ); let published_v1 = vec![(u1.clone(), vec![1u8, 2, 3]), (u2.clone(), vec![9u8, 8, 7])]; let n = store .apply_rrdp_snapshot(notification_uri, &published_v1) .expect("apply snapshot v1"); assert_eq!(n, 2); assert_eq!( store.get_raw(&u1).expect("get_raw u1"), Some(vec![1u8, 2, 3]) ); assert_eq!( store.get_raw(&u2).expect("get_raw u2"), Some(vec![9u8, 8, 7]) ); assert!( store .rrdp_object_index_contains(notification_uri, &u1) .expect("contains u1") ); assert!( store .rrdp_object_index_contains(notification_uri, &u2) .expect("contains u2") ); let mut listed: Vec = store .rrdp_object_index_iter(notification_uri) .expect("iter") .collect(); listed.sort(); assert_eq!(listed, vec![u1.clone(), u2.clone()]); let published_v2 = vec![(u2.clone(), vec![0u8, 1, 2, 3])]; store .apply_rrdp_snapshot(notification_uri, &published_v2) .expect("apply snapshot v2"); assert_eq!(store.get_raw(&u1).expect("get_raw removed"), None); assert_eq!( store.get_raw(&u2).expect("get_raw updated"), Some(vec![0u8, 1, 2, 3]) ); let u3 = "rsync://rpki.example.test/repo/obj3.crl".to_string(); let ops = vec![ RrdpDeltaOp::Upsert { rsync_uri: u3.clone(), bytes: vec![4u8, 5, 6], }, RrdpDeltaOp::Delete { rsync_uri: u2.clone(), }, ]; let applied = store .apply_rrdp_delta(notification_uri, &ops) .expect("apply delta"); assert_eq!(applied, 2); assert_eq!(store.get_raw(&u2).expect("get_raw deleted"), None); assert_eq!( store.get_raw(&u3).expect("get_raw u3"), Some(vec![4u8, 5, 6]) ); let prefix = b"rsync://rpki.example.test/repo/"; let mut got: Vec = store .raw_iter_prefix(prefix) .expect("raw_iter_prefix") .map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key")) .collect(); got.sort(); assert_eq!(got, vec![u3.clone()]); let all: Vec = store .raw_iter_all() .expect("raw_iter_all") .map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key")) .collect(); assert!(all.contains(&u3)); let cleared = store .rrdp_object_index_clear(notification_uri) .expect("clear"); assert!(cleared >= 1); assert!( !store .rrdp_object_index_contains(notification_uri, &u3) .expect("contains after clear") ); } #[test] fn apply_rrdp_delta_empty_ops_is_noop() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let n = store .apply_rrdp_delta("https://rrdp.example.test/notification.xml", &[]) .expect("apply empty delta"); assert_eq!(n, 0); } #[test] fn repository_view_and_raw_by_hash_roundtrip() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let entry1 = sample_repository_view_entry("rsync://example.test/repo/a.cer", b"object-a"); let entry2 = sample_repository_view_entry("rsync://example.test/repo/sub/b.roa", b"object-b"); store .put_repository_view_entry(&entry1) .expect("put repository view entry1"); store .put_repository_view_entry(&entry2) .expect("put repository view entry2"); let got1 = store .get_repository_view_entry(&entry1.rsync_uri) .expect("get repository view entry1") .expect("entry1 exists"); assert_eq!(got1, entry1); let got_prefix = store .list_repository_view_entries_with_prefix("rsync://example.test/repo/sub/") .expect("list repository view prefix"); assert_eq!(got_prefix, vec![entry2.clone()]); store .delete_repository_view_entry(&entry1.rsync_uri) .expect("delete repository view entry1"); assert!( store .get_repository_view_entry(&entry1.rsync_uri) .expect("get deleted repository view entry1") .is_none() ); let raw = sample_raw_by_hash_entry(b"raw-der-object".to_vec()); store .put_raw_by_hash_entry(&raw) .expect("put raw_by_hash entry"); let got_raw = store .get_raw_by_hash_entry(&raw.sha256_hex) .expect("get raw_by_hash entry") .expect("raw entry exists"); assert_eq!(got_raw, raw); } #[test] fn repository_view_and_raw_by_hash_validation_errors_are_reported() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let invalid_view = RepositoryViewEntry { rsync_uri: "rsync://example.test/repo/withdrawn.cer".to_string(), current_hash: None, repository_source: None, object_type: None, state: RepositoryViewState::Present, }; let err = store .put_repository_view_entry(&invalid_view) .expect_err("missing current_hash must fail"); assert!(err.to_string().contains("current_hash is required")); let invalid_raw = RawByHashEntry { sha256_hex: sha256_hex(b"expected"), bytes: b"actual".to_vec(), origin_uris: vec!["rsync://example.test/repo/object.cer".to_string()], object_type: None, encoding: None, }; let err = store .put_raw_by_hash_entry(&invalid_raw) .expect_err("mismatched raw_by_hash entry must fail"); assert!(err.to_string().contains("does not match bytes")); } #[test] fn vcir_roundtrip_and_validation_failures_are_reported() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let vcir = sample_vcir("rsync://example.test/repo/current.mft"); store.put_vcir(&vcir).expect("put vcir"); let got = store .get_vcir(&vcir.manifest_rsync_uri) .expect("get vcir") .expect("vcir exists"); assert_eq!(got, vcir); let mut invalid = sample_vcir("rsync://example.test/repo/invalid.mft"); invalid.summary.local_vrp_count = 9; let err = store .put_vcir(&invalid) .expect_err("invalid vcir must fail"); assert!(err.to_string().contains("local_vrp_count=9")); let mut invalid = sample_vcir("rsync://example.test/repo/invalid-2.mft"); invalid.instance_gate.instance_effective_until = pack_time(11); let err = store .put_vcir(&invalid) .expect_err("invalid instance gate must fail"); assert!(err.to_string().contains("instance_effective_until")); store .delete_vcir(&vcir.manifest_rsync_uri) .expect("delete vcir"); assert!( store .get_vcir(&vcir.manifest_rsync_uri) .expect("get deleted vcir") .is_none() ); } #[test] fn audit_rule_index_roundtrip_for_roa_and_aspa() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let roa = sample_audit_rule_entry(AuditRuleKind::Roa); let aspa = sample_audit_rule_entry(AuditRuleKind::Aspa); store .put_audit_rule_index_entry(&roa) .expect("put roa audit rule entry"); store .put_audit_rule_index_entry(&aspa) .expect("put aspa audit rule entry"); let got_roa = store .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) .expect("get roa audit rule entry") .expect("roa entry exists"); let got_aspa = store .get_audit_rule_index_entry(AuditRuleKind::Aspa, &aspa.rule_hash) .expect("get aspa audit rule entry") .expect("aspa entry exists"); assert_eq!(got_roa, roa); assert_eq!(got_aspa, aspa); store .delete_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) .expect("delete roa audit rule entry"); assert!( store .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) .expect("get deleted roa audit rule entry") .is_none() ); let mut invalid = sample_audit_rule_entry(AuditRuleKind::Roa); invalid.rule_hash = "bad".to_string(); let err = store .put_audit_rule_index_entry(&invalid) .expect_err("invalid audit rule hash must fail"); assert!(err.to_string().contains("64-character")); } #[test] fn storage_helpers_cover_optional_validation_paths() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let withdrawn = RepositoryViewEntry { rsync_uri: "rsync://example.test/repo/withdrawn.cer".to_string(), current_hash: Some(sha256_hex(b"withdrawn")), repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), object_type: Some("cer".to_string()), state: RepositoryViewState::Withdrawn, }; withdrawn .validate_internal() .expect("withdrawn repository view validates"); let raw = RawByHashEntry::from_bytes(sha256_hex(b"helper-bytes"), b"helper-bytes".to_vec()); raw.validate_internal() .expect("raw_by_hash helper validates"); let empty_raw = RawByHashEntry { sha256_hex: sha256_hex(b"x"), bytes: Vec::new(), origin_uris: Vec::new(), object_type: None, encoding: None, }; let err = empty_raw .validate_internal() .expect_err("empty raw bytes must fail"); assert!(err.to_string().contains("bytes must not be empty")); let duplicate_origin_raw = RawByHashEntry { sha256_hex: sha256_hex(b"dup-origin"), bytes: b"dup-origin".to_vec(), origin_uris: vec![ "rsync://example.test/repo/object.cer".to_string(), "rsync://example.test/repo/object.cer".to_string(), ], object_type: Some("cer".to_string()), encoding: Some("der".to_string()), }; let err = duplicate_origin_raw .validate_internal() .expect_err("duplicate origin URI must fail"); assert!(err.to_string().contains("duplicate origin URI")); store .put_raw_batch(vec![( "rsync://example.test/repo/raw.cer".to_string(), vec![1, 2, 3], )]) .expect("put_raw_batch stores entries"); assert_eq!( store .get_raw("rsync://example.test/repo/raw.cer") .expect("get raw"), Some(vec![1, 2, 3]) ); store .delete_raw("rsync://example.test/repo/raw.cer") .expect("delete raw entry"); assert!( store .get_raw("rsync://example.test/repo/raw.cer") .expect("get deleted raw") .is_none() ); store .put_rrdp_state("https://rrdp.example.test/notification.xml", b"state") .expect("put rrdp state"); assert_eq!( store .get_rrdp_state("https://rrdp.example.test/notification.xml") .expect("get rrdp state"), Some(b"state".to_vec()) ); store .delete_rrdp_state("https://rrdp.example.test/notification.xml") .expect("delete rrdp state"); assert!( store .get_rrdp_state("https://rrdp.example.test/notification.xml") .expect("get deleted rrdp state") .is_none() ); } #[test] fn rrdp_source_optional_fields_and_owner_without_hash_validate() { let source = RrdpSourceRecord { notify_uri: "https://rrdp.example.test/notification.xml".to_string(), last_session_id: None, last_serial: None, first_seen_at: pack_time(0), last_seen_at: pack_time(1), last_sync_at: None, sync_state: RrdpSourceSyncState::Empty, last_snapshot_uri: None, last_snapshot_hash: None, last_error: Some("network timeout".to_string()), }; source .validate_internal() .expect("source with optional fields validates"); let owner = RrdpUriOwnerRecord { rsync_uri: "rsync://example.test/repo/object.cer".to_string(), notify_uri: "https://rrdp.example.test/notification.xml".to_string(), current_hash: None, last_confirmed_session_id: "session-1".to_string(), last_confirmed_serial: 5, last_changed_at: pack_time(5), owner_state: RrdpUriOwnerState::Withdrawn, }; owner .validate_internal() .expect("owner without hash validates when withdrawn"); } #[test] fn rrdp_source_binding_records_roundtrip_and_prefix_iteration() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let notify_uri = "https://rrdp.example.test/notification.xml"; let source = sample_rrdp_source_record(notify_uri); store .put_rrdp_source_record(&source) .expect("put rrdp source record"); let got_source = store .get_rrdp_source_record(notify_uri) .expect("get rrdp source record") .expect("rrdp source exists"); assert_eq!(got_source, source); let member1 = sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/a.cer", 1); let member2 = sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/b.roa", 2); let other_member = sample_rrdp_source_member_record( "https://other.example.test/notification.xml", "rsync://other.example.test/repo/c.cer", 3, ); store .put_rrdp_source_member_record(&member1) .expect("put member1"); store .put_rrdp_source_member_record(&member2) .expect("put member2"); store .put_rrdp_source_member_record(&other_member) .expect("put other member"); let mut members = store .list_rrdp_source_member_records(notify_uri) .expect("list rrdp source members"); members.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri)); assert_eq!(members, vec![member1.clone(), member2.clone()]); let got_member = store .get_rrdp_source_member_record(notify_uri, &member1.rsync_uri) .expect("get member1") .expect("member1 exists"); assert_eq!(got_member, member1); let owner = sample_rrdp_uri_owner_record(notify_uri, &member1.rsync_uri); store .put_rrdp_uri_owner_record(&owner) .expect("put uri owner record"); let got_owner = store .get_rrdp_uri_owner_record(&member1.rsync_uri) .expect("get uri owner record") .expect("uri owner exists"); assert_eq!(got_owner, owner); store .delete_rrdp_uri_owner_record(&member1.rsync_uri) .expect("delete uri owner record"); assert!( store .get_rrdp_uri_owner_record(&member1.rsync_uri) .expect("get deleted uri owner") .is_none() ); let mut invalid_source = sample_rrdp_source_record("https://invalid.example/notification.xml"); invalid_source.last_snapshot_hash = Some("bad".to_string()); let err = store .put_rrdp_source_record(&invalid_source) .expect_err("invalid source hash must fail"); assert!(err.to_string().contains("last_snapshot_hash")); let invalid_member = RrdpSourceMemberRecord { notify_uri: notify_uri.to_string(), rsync_uri: "rsync://example.test/repo/deleted.cer".to_string(), current_hash: None, object_type: None, present: true, last_confirmed_session_id: "session-1".to_string(), last_confirmed_serial: 10, last_changed_at: pack_time(10), }; let err = store .put_rrdp_source_member_record(&invalid_member) .expect_err("present member without hash must fail"); assert!(err.to_string().contains("current_hash is required")); } }