rpki/src/storage.rs

2166 lines
75 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::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<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,
}
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,
}
#[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 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;
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<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 })
}
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<u8>)>) -> StorageResult<usize> {
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<Option<Vec<u8>>> {
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<Option<Vec<u8>>> {
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<u8> {
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<u8> {
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<bool> {
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<impl Iterator<Item = String> + '_> {
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<usize> {
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<Box<[u8]>> = 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<u8>)],
) -> StorageResult<usize> {
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<String> = 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<usize> {
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<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 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()?;
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<Option<RawByHashEntry>> {
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 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<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 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 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 raw_iter_prefix<'a>(
&'a self,
prefix: &'a [u8],
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, 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<impl Iterator<Item = (Box<[u8]>, 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<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,
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<String> = 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<String> = 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<String> = 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"));
}
}