rpki/src/storage.rs

2402 lines
86 KiB
Rust

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::blob_store::{ExternalRawStoreDb, RawObjectStore};
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
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_REPOSITORY_VIEW,
CF_RAW_BY_HASH,
CF_VCIR,
CF_AUDIT_RULE_INDEX,
CF_RRDP_SOURCE,
CF_RRDP_SOURCE_MEMBER,
CF_RRDP_URI_OWNER,
];
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 AUDIT_ROUTER_KEY_RULE_KEY_PREFIX: &str = "audit:router_key_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<ColumnFamilyDescriptor> {
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<T> = Result<T, StorageError>;
pub struct RocksStore {
db: DB,
external_raw_store: Option<ExternalRawStoreDb>,
}
pub mod pack {
pub use super::{PackFile, PackTime};
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RrdpDeltaOp {
Upsert { rsync_uri: String, bytes: Vec<u8> },
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<String>,
pub repository_source: Option<String>,
pub object_type: Option<String>,
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<u8>,
pub origin_uris: Vec<String>,
pub object_type: Option<String>,
pub encoding: Option<String>,
}
impl RawByHashEntry {
pub fn from_bytes(sha256_hex: impl Into<String>, bytes: Vec<u8>) -> 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<u8>,
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<String>,
pub child_effective_ip_resources: Option<IpResourceSet>,
pub child_effective_as_resources: Option<AsResourceSet>,
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,
RouterKey,
}
#[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<String>,
}
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<String>,
pub sha256: String,
pub object_type: Option<String>,
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 local_router_key_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<String>,
pub warning_count: u32,
pub audit_flags: Vec<String>,
}
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<String>,
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<VcirChildEntry>,
pub local_outputs: Vec<VcirLocalOutput>,
pub related_artifacts: Vec<VcirRelatedArtifact>,
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;
let mut router_key_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,
VcirOutputType::RouterKey => router_key_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.local_router_key_count != router_key_count {
return Err(StorageError::InvalidData {
entity: "vcir.summary",
detail: format!(
"local_router_key_count={} does not match local_outputs count {}",
self.summary.local_router_key_count, router_key_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,
RouterKey,
}
impl AuditRuleKind {
fn key_prefix(self) -> &'static str {
match self {
Self::Roa => AUDIT_ROA_RULE_KEY_PREFIX,
Self::Aspa => AUDIT_ASPA_RULE_KEY_PREFIX,
Self::RouterKey => AUDIT_ROUTER_KEY_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<String>,
pub last_serial: Option<u64>,
pub first_seen_at: PackTime,
pub last_seen_at: PackTime,
pub last_sync_at: Option<PackTime>,
pub sync_state: RrdpSourceSyncState,
pub last_snapshot_uri: Option<String>,
pub last_snapshot_hash: Option<String>,
pub last_error: Option<String>,
}
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<String>,
pub object_type: Option<String>,
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<String>,
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<Self> {
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,
external_raw_store: None,
})
}
pub fn open_with_external_raw_store(
path: &Path,
raw_store_path: &Path,
) -> StorageResult<Self> {
let mut store = Self::open(path)?;
store.external_raw_store = Some(ExternalRawStoreDb::open(raw_store_path)?);
Ok(store)
}
fn cf(&self, name: &'static str) -> StorageResult<&ColumnFamily> {
self.db
.cf_handle(name)
.ok_or(StorageError::MissingColumnFamily(name))
}
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<Option<RepositoryViewEntry>> {
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::<RepositoryViewEntry>(&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 put_projection_batch(
&self,
repository_view_entries: &[RepositoryViewEntry],
member_records: &[RrdpSourceMemberRecord],
owner_records: &[RrdpUriOwnerRecord],
) -> StorageResult<()> {
if repository_view_entries.is_empty()
&& member_records.is_empty()
&& owner_records.is_empty()
{
return Ok(());
}
let repo_cf = self.cf(CF_REPOSITORY_VIEW)?;
let member_cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
let owner_cf = self.cf(CF_RRDP_URI_OWNER)?;
let mut batch = WriteBatch::default();
for entry in repository_view_entries {
entry.validate_internal()?;
let key = repository_view_key(&entry.rsync_uri);
let value = encode_cbor(entry, "repository_view")?;
batch.put_cf(repo_cf, key.as_bytes(), value);
}
for record in member_records {
record.validate_internal()?;
let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri);
let value = encode_cbor(record, "rrdp_source_member")?;
batch.put_cf(member_cf, key.as_bytes(), value);
}
for record in owner_records {
record.validate_internal()?;
let key = rrdp_uri_owner_key(&record.rsync_uri);
let value = encode_cbor(record, "rrdp_uri_owner")?;
batch.put_cf(owner_cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn list_repository_view_entries_with_prefix(
&self,
rsync_uri_prefix: &str,
) -> StorageResult<Vec<RepositoryViewEntry>> {
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::<RepositoryViewEntry>(&value, "repository_view")?;
entry.validate_internal()?;
Ok(entry)
})
.collect()
}
pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
entry.validate_internal()?;
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.put_raw_entry(entry);
}
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 put_raw_by_hash_entries_batch(&self, entries: &[RawByHashEntry]) -> StorageResult<()> {
if entries.is_empty() {
return Ok(());
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.put_raw_entries_batch(entries);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let mut batch = WriteBatch::default();
for entry in entries {
entry.validate_internal()?;
let key = raw_by_hash_key(&entry.sha256_hex);
let value = encode_cbor(entry, "raw_by_hash")?;
batch.put_cf(cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn put_raw_by_hash_entries_batch_unchecked(
&self,
entries: &[RawByHashEntry],
) -> StorageResult<()> {
if entries.is_empty() {
return Ok(());
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.put_raw_entries_batch(entries);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let mut batch = WriteBatch::default();
for entry in entries {
let key = raw_by_hash_key(&entry.sha256_hex);
let value = encode_cbor(entry, "raw_by_hash")?;
batch.put_cf(cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn delete_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult<()> {
validate_sha256_hex("raw_by_hash.sha256_hex", sha256_hex)?;
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.delete_raw_entry(sha256_hex);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let key = raw_by_hash_key(sha256_hex);
self.db
.delete_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.get_raw_entry(sha256_hex);
}
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::<RawByHashEntry>(&bytes, "raw_by_hash")?;
entry.validate_internal()?;
Ok(Some(entry))
}
pub fn get_raw_by_hash_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.get_raw_entries_batch(sha256_hexes);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let keys: Vec<String> = sha256_hexes
.iter()
.map(|hash| raw_by_hash_key(hash))
.collect();
self.db
.multi_get_cf(keys.iter().map(|key| (cf, key.as_bytes())))
.into_iter()
.map(|res| {
let maybe = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
match maybe {
Some(bytes) => {
let entry = decode_cbor::<RawByHashEntry>(&bytes, "raw_by_hash")?;
entry.validate_internal()?;
Ok(Some(entry))
}
None => Ok(None),
}
})
.collect()
}
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 replace_vcir_and_audit_rule_indexes(
&self,
previous: Option<&ValidatedCaInstanceResult>,
vcir: &ValidatedCaInstanceResult,
) -> StorageResult<()> {
vcir.validate_internal()?;
let vcir_cf = self.cf(CF_VCIR)?;
let audit_cf = self.cf(CF_AUDIT_RULE_INDEX)?;
let mut batch = WriteBatch::default();
let vcir_key = vcir_key(&vcir.manifest_rsync_uri);
let vcir_value = encode_cbor(vcir, "vcir")?;
batch.put_cf(vcir_cf, vcir_key.as_bytes(), vcir_value);
if let Some(previous) = previous {
for output in &previous.local_outputs {
let Some(kind) = audit_rule_kind_for_output_type(output.output_type) else {
continue;
};
let key = audit_rule_key(kind, &output.rule_hash);
batch.delete_cf(audit_cf, key.as_bytes());
}
}
for output in &vcir.local_outputs {
let Some(kind) = audit_rule_kind_for_output_type(output.output_type) else {
continue;
};
let entry = AuditRuleIndexEntry {
kind,
rule_hash: output.rule_hash.clone(),
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
source_object_uri: output.source_object_uri.clone(),
source_object_hash: output.source_object_hash.clone(),
output_id: output.output_id.clone(),
item_effective_until: output.item_effective_until.clone(),
};
entry.validate_internal()?;
let key = audit_rule_key(kind, &entry.rule_hash);
let value = encode_cbor(&entry, "audit_rule_index")?;
batch.put_cf(audit_cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn get_vcir(
&self,
manifest_rsync_uri: &str,
) -> StorageResult<Option<ValidatedCaInstanceResult>> {
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::<ValidatedCaInstanceResult>(&bytes, "vcir")?;
vcir.validate_internal()?;
Ok(Some(vcir))
}
pub fn list_vcirs(&self) -> StorageResult<Vec<ValidatedCaInstanceResult>> {
let cf = self.cf(CF_VCIR)?;
let mode = IteratorMode::Start;
let mut out = Vec::new();
for res in self.db.iterator_cf(cf, mode) {
let (_key, bytes) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
let vcir = decode_cbor::<ValidatedCaInstanceResult>(&bytes, "vcir")?;
vcir.validate_internal()?;
out.push(vcir);
}
Ok(out)
}
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<Option<AuditRuleIndexEntry>> {
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::<AuditRuleIndexEntry>(&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<Option<RrdpSourceRecord>> {
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::<RrdpSourceRecord>(&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<Option<RrdpSourceMemberRecord>> {
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::<RrdpSourceMemberRecord>(&bytes, "rrdp_source_member")?;
record.validate_internal()?;
Ok(Some(record))
}
pub fn list_rrdp_source_member_records(
&self,
notify_uri: &str,
) -> StorageResult<Vec<RrdpSourceMemberRecord>> {
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::<RrdpSourceMemberRecord>(&value, "rrdp_source_member")?;
record.validate_internal()?;
Ok(record)
})
.collect()
}
pub fn list_current_rrdp_source_members(
&self,
notify_uri: &str,
) -> StorageResult<Vec<RrdpSourceMemberRecord>> {
let mut records = self.list_rrdp_source_member_records(notify_uri)?;
records.retain(|record| record.present);
records.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
Ok(records)
}
pub fn is_current_rrdp_source_member(
&self,
notify_uri: &str,
rsync_uri: &str,
) -> StorageResult<bool> {
Ok(matches!(
self.get_rrdp_source_member_record(notify_uri, rsync_uri)?,
Some(record) if record.present
))
}
pub fn load_current_object_bytes_by_uri(
&self,
rsync_uri: &str,
) -> StorageResult<Option<Vec<u8>>> {
let Some(view) = self.get_repository_view_entry(rsync_uri)? else {
return Ok(None);
};
match view.state {
RepositoryViewState::Withdrawn => Ok(None),
RepositoryViewState::Present | RepositoryViewState::Replaced => {
let hash = view
.current_hash
.as_deref()
.ok_or(StorageError::InvalidData {
entity: "repository_view",
detail: format!("current_hash missing for current object URI: {rsync_uri}"),
})?;
let bytes = self.get_blob_bytes(hash)?.ok_or(StorageError::InvalidData {
entity: "repository_view",
detail: format!(
"raw_by_hash entry missing for current object URI: {rsync_uri} (hash={hash})"
),
})?;
Ok(Some(bytes))
}
}
}
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<Option<RrdpUriOwnerRecord>> {
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::<RrdpUriOwnerRecord>(&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 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_kind_for_output_type(output_type: VcirOutputType) -> Option<AuditRuleKind> {
match output_type {
VcirOutputType::Vrp => Some(AuditRuleKind::Roa),
VcirOutputType::Aspa => Some(AuditRuleKind::Aspa),
VcirOutputType::RouterKey => Some(AuditRuleKind::RouterKey),
}
}
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<T: Serialize>(value: &T, entity: &'static str) -> StorageResult<Vec<u8>> {
serde_cbor::to_vec(value).map_err(|e| StorageError::Codec {
entity,
detail: e.to_string(),
})
}
fn decode_cbor<T: DeserializeOwned>(bytes: &[u8], entity: &'static str) -> StorageResult<T> {
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<time::OffsetDateTime> {
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<u8>,
pub sha256: [u8; 32],
}
impl PackFile {
pub fn new(rsync_uri: impl Into<String>, bytes: Vec<u8>, sha256: [u8; 32]) -> Self {
Self {
rsync_uri: rsync_uri.into(),
bytes,
sha256,
}
}
pub fn from_bytes_compute_sha256(rsync_uri: impl Into<String>, bytes: Vec<u8>) -> 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<time::OffsetDateTime, String> {
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<u8>) -> 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,
local_router_key_count: 0,
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",
AuditRuleKind::RouterKey => b"router-key-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(),
AuditRuleKind::RouterKey => "rsync://example.test/repo/router.cer".to_string(),
},
source_object_hash: sha256_hex(match kind {
AuditRuleKind::Roa => b"roa-object",
AuditRuleKind::Aspa => b"aspa-object",
AuditRuleKind::RouterKey => b"router-key-object",
}),
output_id: match kind {
AuditRuleKind::Roa => "vrp-1".to_string(),
AuditRuleKind::Aspa => "aspa-1".to_string(),
AuditRuleKind::RouterKey => "router-key-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 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 raw_by_hash_routes_to_external_raw_store_when_configured() {
let td = tempfile::tempdir().expect("tempdir");
let main_db = td.path().join("main-db");
let raw_db = td.path().join("raw-store.db");
let raw = sample_raw_by_hash_entry(b"external-raw".to_vec());
{
let store =
RocksStore::open_with_external_raw_store(&main_db, &raw_db).expect("open store");
store
.put_raw_by_hash_entry(&raw)
.expect("put external raw");
let got = store
.get_raw_by_hash_entry(&raw.sha256_hex)
.expect("get external raw")
.expect("raw exists");
assert_eq!(got, raw);
}
let main_store = RocksStore::open(&main_db).expect("open main only");
assert!(
main_store
.get_raw_by_hash_entry(&raw.sha256_hex)
.expect("read main store")
.is_none(),
"main db should not contain raw entry when external raw store is configured"
);
}
#[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 list_vcirs_returns_all_entries() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let vcir1 = sample_vcir("rsync://example.test/repo/a.mft");
let vcir2 = sample_vcir("rsync://example.test/repo/b.mft");
store.put_vcir(&vcir1).expect("put vcir1");
store.put_vcir(&vcir2).expect("put vcir2");
let mut got = store.list_vcirs().expect("list vcirs");
got.sort_by(|a, b| a.manifest_rsync_uri.cmp(&b.manifest_rsync_uri));
assert_eq!(got, vec![vcir1, vcir2]);
}
#[test]
fn audit_rule_index_roundtrip_for_roa_aspa_and_router_key() {
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);
let router_key = sample_audit_rule_entry(AuditRuleKind::RouterKey);
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");
store
.put_audit_rule_index_entry(&router_key)
.expect("put router key 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");
let got_router_key = store
.get_audit_rule_index_entry(AuditRuleKind::RouterKey, &router_key.rule_hash)
.expect("get router key audit rule entry")
.expect("router key entry exists");
assert_eq!(got_roa, roa);
assert_eq!(got_aspa, aspa);
assert_eq!(got_router_key, router_key);
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 replace_vcir_and_audit_rule_indexes_replaces_previous_entries_in_one_step() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let mut previous = sample_vcir("rsync://example.test/repo/current.mft");
previous.local_outputs = vec![VcirLocalOutput {
output_id: "old-output".to_string(),
output_type: VcirOutputType::Vrp,
item_effective_until: pack_time(10),
source_object_uri: "rsync://example.test/repo/old.roa".to_string(),
source_object_type: "roa".to_string(),
source_object_hash: sha256_hex(b"old-roa"),
source_ee_cert_hash: sha256_hex(b"old-ee"),
payload_json: "{}".to_string(),
rule_hash: sha256_hex(b"old-rule"),
validation_path_hint: vec![previous.manifest_rsync_uri.clone()],
}];
previous.summary.local_vrp_count = 1;
previous.summary.local_aspa_count = 0;
previous.summary.local_router_key_count = 0;
store
.replace_vcir_and_audit_rule_indexes(None, &previous)
.expect("store previous vcir");
assert!(
store
.get_audit_rule_index_entry(
AuditRuleKind::Roa,
&previous.local_outputs[0].rule_hash
)
.expect("get old audit entry")
.is_some()
);
let mut current = sample_vcir("rsync://example.test/repo/current.mft");
current.local_outputs = vec![VcirLocalOutput {
output_id: "new-output".to_string(),
output_type: VcirOutputType::Aspa,
item_effective_until: pack_time(11),
source_object_uri: "rsync://example.test/repo/new.asa".to_string(),
source_object_type: "aspa".to_string(),
source_object_hash: sha256_hex(b"new-aspa"),
source_ee_cert_hash: sha256_hex(b"new-ee"),
payload_json: "{}".to_string(),
rule_hash: sha256_hex(b"new-rule"),
validation_path_hint: vec![current.manifest_rsync_uri.clone()],
}];
current.summary.local_vrp_count = 0;
current.summary.local_aspa_count = 1;
store
.replace_vcir_and_audit_rule_indexes(Some(&previous), &current)
.expect("replace vcir and audit indexes");
let got = store
.get_vcir(&current.manifest_rsync_uri)
.expect("get replaced vcir")
.expect("vcir exists");
assert_eq!(got, current);
assert!(
store
.get_audit_rule_index_entry(
AuditRuleKind::Roa,
&previous.local_outputs[0].rule_hash
)
.expect("get deleted old audit entry")
.is_none()
);
assert!(
store
.get_audit_rule_index_entry(
AuditRuleKind::Aspa,
&current.local_outputs[0].rule_hash
)
.expect("get new audit entry")
.is_some()
);
}
#[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"));
}
#[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"));
}
#[test]
fn projection_batch_roundtrip_writes_repository_view_member_and_owner_records() {
let dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(dir.path()).expect("open store");
let view = RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some(hex::encode([1u8; 32])),
repository_source: Some("https://example.test/notify.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
};
let member = RrdpSourceMemberRecord {
notify_uri: "https://example.test/notify.xml".to_string(),
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some(hex::encode([1u8; 32])),
object_type: Some("roa".to_string()),
present: true,
last_confirmed_session_id: "session-1".to_string(),
last_confirmed_serial: 7,
last_changed_at: pack_time(1),
};
let owner = RrdpUriOwnerRecord {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
notify_uri: "https://example.test/notify.xml".to_string(),
current_hash: Some(hex::encode([1u8; 32])),
last_confirmed_session_id: "session-1".to_string(),
last_confirmed_serial: 7,
last_changed_at: pack_time(1),
owner_state: RrdpUriOwnerState::Active,
};
store
.put_projection_batch(&[view.clone()], &[member.clone()], &[owner.clone()])
.expect("write projection batch");
assert_eq!(
store
.get_repository_view_entry(&view.rsync_uri)
.expect("get view")
.expect("present view"),
view
);
assert_eq!(
store
.get_rrdp_source_member_record(&member.notify_uri, &member.rsync_uri)
.expect("get member")
.expect("present member"),
member
);
assert_eq!(
store
.get_rrdp_uri_owner_record(&owner.rsync_uri)
.expect("get owner")
.expect("present owner"),
owner
);
}
#[test]
fn current_rrdp_source_member_helpers_filter_present_records() {
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 mut present_a =
sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/a.cer", 1);
let mut withdrawn_b =
sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/b.roa", 2);
withdrawn_b.present = false;
let present_c =
sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/c.crl", 3);
let other_source = sample_rrdp_source_member_record(
"https://other.example.test/notification.xml",
"rsync://other.example.test/repo/x.cer",
4,
);
present_a.last_confirmed_serial = 10;
store
.put_rrdp_source_member_record(&present_a)
.expect("put present a");
store
.put_rrdp_source_member_record(&withdrawn_b)
.expect("put withdrawn b");
store
.put_rrdp_source_member_record(&present_c)
.expect("put present c");
store
.put_rrdp_source_member_record(&other_source)
.expect("put other source");
let members = store
.list_current_rrdp_source_members(notify_uri)
.expect("list current members");
assert_eq!(
members
.iter()
.map(|record| record.rsync_uri.as_str())
.collect::<Vec<_>>(),
vec![
"rsync://example.test/repo/a.cer",
"rsync://example.test/repo/c.crl",
]
);
assert!(
store
.is_current_rrdp_source_member(notify_uri, &present_a.rsync_uri)
.expect("current a")
);
assert!(
!store
.is_current_rrdp_source_member(notify_uri, &withdrawn_b.rsync_uri)
.expect("withdrawn b")
);
assert!(
!store
.is_current_rrdp_source_member(notify_uri, &other_source.rsync_uri)
.expect("other source")
);
}
#[test]
fn load_current_object_bytes_by_uri_uses_repository_view_and_raw_by_hash() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let present_bytes = b"present-object".to_vec();
let present_hash = sha256_hex(&present_bytes);
let mut present_raw =
RawByHashEntry::from_bytes(present_hash.clone(), present_bytes.clone());
present_raw
.origin_uris
.push("rsync://example.test/repo/present.roa".to_string());
present_raw.object_type = Some("roa".to_string());
store
.put_raw_by_hash_entry(&present_raw)
.expect("put present raw");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/present.roa".to_string(),
current_hash: Some(present_hash),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put present view");
let replaced_bytes = b"replaced-object".to_vec();
let replaced_hash = sha256_hex(&replaced_bytes);
let mut replaced_raw =
RawByHashEntry::from_bytes(replaced_hash.clone(), replaced_bytes.clone());
replaced_raw
.origin_uris
.push("rsync://example.test/repo/replaced.cer".to_string());
replaced_raw.object_type = Some("cer".to_string());
store
.put_raw_by_hash_entry(&replaced_raw)
.expect("put replaced raw");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/replaced.cer".to_string(),
current_hash: Some(replaced_hash),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("cer".to_string()),
state: RepositoryViewState::Replaced,
})
.expect("put replaced view");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/withdrawn.crl".to_string(),
current_hash: Some(sha256_hex(b"withdrawn")),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("crl".to_string()),
state: RepositoryViewState::Withdrawn,
})
.expect("put withdrawn view");
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/present.roa")
.expect("load present"),
Some(present_bytes)
);
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/replaced.cer")
.expect("load replaced"),
Some(replaced_bytes)
);
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/withdrawn.crl")
.expect("load withdrawn"),
None
);
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/missing.roa")
.expect("load missing"),
None
);
}
#[test]
fn load_current_object_bytes_by_uri_errors_when_raw_by_hash_is_missing() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let rsync_uri = "rsync://example.test/repo/missing.cer";
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: rsync_uri.to_string(),
current_hash: Some(hex::encode([0x11; 32])),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("cer".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
let err = store
.load_current_object_bytes_by_uri(rsync_uri)
.expect_err("missing raw_by_hash should error");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
}