mod codec;
mod operation;
#[cfg(test)]
#[path = "recovery/tests.rs"]
mod tests;
use std::fmt;
use aes_gcm::{Aes256Gcm, KeyInit};
use zeroize::Zeroizing;
use super::{CollectionName, PolyglotError};
const MAX_APPLICATION_NAMESPACE_BYTES: usize = 128;
const MAX_KEY_ID_BYTES: usize = 64;
const MIN_SNAPSHOT_BYTES: usize = 1_024;
const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
const MAX_DOCUMENTS: u32 = 100_000;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DocumentRecoveryError {
#[error("document recovery key must have a portable ID and exactly 32 bytes")]
InvalidKey,
#[error("document recovery application namespace is invalid")]
InvalidBinding,
#[error("document recovery policy is invalid")]
InvalidPolicy,
#[error("document recovery envelope is malformed or oversized")]
InvalidEnvelope,
#[error("document recovery envelope version is unsupported")]
UnsupportedVersion,
#[error("document recovery snapshot requires a different key ID")]
KeyIdMismatch,
#[error("operating-system randomness is unavailable")]
RandomnessUnavailable,
#[error("document recovery snapshot encryption failed")]
EncryptionFailed,
#[error("document recovery snapshot authentication failed")]
AuthenticationFailed,
#[error("document recovery snapshot payload is invalid")]
InvalidPayload,
#[error("document recovery requires JSON-object models without id or _id fields")]
NonPortableModel,
#[error("document recovery capacity was exceeded")]
CapacityExceeded,
#[error("document recovery inventory was not strictly ordered")]
InvalidInventory,
#[error("document recovery source changed during export")]
SourceChanged,
#[error("document recovery destination conflicts with the snapshot")]
DestinationConflict,
#[error("document recovery destination changed during restoration")]
DestinationChanged,
#[error("document repository operation failed")]
Repository(#[source] PolyglotError),
}
impl From<PolyglotError> for DocumentRecoveryError {
fn from(error: PolyglotError) -> Self {
Self::Repository(error)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocumentRecoveryBinding {
application_namespace: String,
collection: CollectionName,
}
impl DocumentRecoveryBinding {
pub fn try_new(
application_namespace: impl Into<String>,
collection: CollectionName,
) -> Result<Self, DocumentRecoveryError> {
let application_namespace = application_namespace.into();
if !valid_portable_label(&application_namespace, MAX_APPLICATION_NAMESPACE_BYTES) {
return Err(DocumentRecoveryError::InvalidBinding);
}
Ok(Self {
application_namespace,
collection,
})
}
pub fn application_namespace(&self) -> &str {
&self.application_namespace
}
pub fn collection(&self) -> &CollectionName {
&self.collection
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DocumentRecoveryPolicy {
page_size: u32,
max_documents: u32,
max_snapshot_bytes: usize,
}
impl DocumentRecoveryPolicy {
pub fn try_new(
page_size: u32,
max_documents: u32,
max_snapshot_bytes: usize,
) -> Result<Self, DocumentRecoveryError> {
if page_size == 0
|| page_size > 500
|| max_documents == 0
|| max_documents > MAX_DOCUMENTS
|| !(MIN_SNAPSHOT_BYTES..=MAX_SNAPSHOT_BYTES).contains(&max_snapshot_bytes)
{
return Err(DocumentRecoveryError::InvalidPolicy);
}
Ok(Self {
page_size,
max_documents,
max_snapshot_bytes,
})
}
pub const fn page_size(self) -> u32 {
self.page_size
}
pub const fn max_documents(self) -> u32 {
self.max_documents
}
pub const fn max_snapshot_bytes(self) -> usize {
self.max_snapshot_bytes
}
}
impl Default for DocumentRecoveryPolicy {
fn default() -> Self {
Self {
page_size: 100,
max_documents: 10_000,
max_snapshot_bytes: 16 * 1024 * 1024,
}
}
}
pub struct DocumentRecoveryKey {
key_id: String,
cipher: Aes256Gcm,
}
impl DocumentRecoveryKey {
pub fn try_new(
key_id: impl Into<String>,
key: [u8; 32],
) -> Result<Self, DocumentRecoveryError> {
let key_id = key_id.into();
if !valid_portable_label(&key_id, MAX_KEY_ID_BYTES) {
return Err(DocumentRecoveryError::InvalidKey);
}
let key = Zeroizing::new(key);
let cipher = Aes256Gcm::new_from_slice(key.as_ref())
.map_err(|_| DocumentRecoveryError::EncryptionFailed)?;
Ok(Self { key_id, cipher })
}
pub fn key_id(&self) -> &str {
&self.key_id
}
}
impl fmt::Debug for DocumentRecoveryKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DocumentRecoveryKey")
.field("key_id", &self.key_id)
.field("key", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct EncryptedDocumentSnapshot(String);
impl EncryptedDocumentSnapshot {
pub fn try_from_envelope(
envelope: impl Into<String>,
policy: DocumentRecoveryPolicy,
) -> Result<Self, DocumentRecoveryError> {
let envelope = envelope.into();
codec::validate_envelope(&envelope, policy)?;
Ok(Self(envelope))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for EncryptedDocumentSnapshot {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("EncryptedDocumentSnapshot([REDACTED])")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DocumentRecoveryReport {
inserted: u32,
replayed: u32,
verified: u32,
}
impl DocumentRecoveryReport {
pub const fn inserted(self) -> u32 {
self.inserted
}
pub const fn replayed(self) -> u32 {
self.replayed
}
pub const fn verified(self) -> u32 {
self.verified
}
}
fn valid_portable_label(value: &str, max_bytes: usize) -> bool {
!value.is_empty()
&& value.len() <= max_bytes
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}
pub use operation::{export_document_snapshot, restore_document_snapshot};