Skip to main content

rullst_orm/polyglot/
recovery.rs

1//! Bounded, authenticated document export and crash-resumable restoration.
2//!
3//! This module provides an application-operated recovery primitive. It does not
4//! claim an atomic cross-store transaction, online replication, deletion of
5//! destination data, or vendor-managed backup durability.
6
7mod codec;
8mod operation;
9
10#[cfg(test)]
11#[path = "recovery/tests.rs"]
12mod tests;
13
14use std::fmt;
15
16use aes_gcm::{Aes256Gcm, KeyInit};
17use zeroize::Zeroizing;
18
19use super::{CollectionName, PolyglotError};
20
21const MAX_APPLICATION_NAMESPACE_BYTES: usize = 128;
22const MAX_KEY_ID_BYTES: usize = 64;
23const MIN_SNAPSHOT_BYTES: usize = 1_024;
24const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
25const MAX_DOCUMENTS: u32 = 100_000;
26
27/// Typed failures that omit document contents, identifiers, keys and paths.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum DocumentRecoveryError {
31    /// Key identifiers and key material must meet the fixed contract.
32    #[error("document recovery key must have a portable ID and exactly 32 bytes")]
33    InvalidKey,
34    /// The application namespace is missing, oversized or non-portable.
35    #[error("document recovery application namespace is invalid")]
36    InvalidBinding,
37    /// A policy field exceeded its documented bound.
38    #[error("document recovery policy is invalid")]
39    InvalidPolicy,
40    /// The serialized envelope is malformed or exceeds its configured bound.
41    #[error("document recovery envelope is malformed or oversized")]
42    InvalidEnvelope,
43    /// The envelope version is not implemented by this release.
44    #[error("document recovery envelope version is unsupported")]
45    UnsupportedVersion,
46    /// The caller supplied a different rotation key.
47    #[error("document recovery snapshot requires a different key ID")]
48    KeyIdMismatch,
49    /// Random nonce generation was unavailable.
50    #[error("operating-system randomness is unavailable")]
51    RandomnessUnavailable,
52    /// Encryption failed without exposing document contents.
53    #[error("document recovery snapshot encryption failed")]
54    EncryptionFailed,
55    /// Key, binding, nonce, ciphertext or tag did not authenticate.
56    #[error("document recovery snapshot authentication failed")]
57    AuthenticationFailed,
58    /// Authenticated plaintext violated the versioned portable schema.
59    #[error("document recovery snapshot payload is invalid")]
60    InvalidPayload,
61    /// The model is not portable across the supported document adapters.
62    #[error("document recovery requires JSON-object models without id or _id fields")]
63    NonPortableModel,
64    /// A configured document or byte ceiling was exhausted.
65    #[error("document recovery capacity was exceeded")]
66    CapacityExceeded,
67    /// An adapter violated stable, strictly increasing inventory order.
68    #[error("document recovery inventory was not strictly ordered")]
69    InvalidInventory,
70    /// The source changed between the two export observations.
71    #[error("document recovery source changed during export")]
72    SourceChanged,
73    /// Existing destination content is extra or differs from the snapshot.
74    #[error("document recovery destination conflicts with the snapshot")]
75    DestinationConflict,
76    /// Destination content changed while restoration was running.
77    #[error("document recovery destination changed during restoration")]
78    DestinationChanged,
79    /// A repository operation failed; its source remains available to callers.
80    #[error("document repository operation failed")]
81    Repository(#[source] PolyglotError),
82}
83
84impl From<PolyglotError> for DocumentRecoveryError {
85    fn from(error: PolyglotError) -> Self {
86        Self::Repository(error)
87    }
88}
89
90/// Trusted application and collection scope authenticated with every snapshot.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct DocumentRecoveryBinding {
93    application_namespace: String,
94    collection: CollectionName,
95}
96
97impl DocumentRecoveryBinding {
98    /// Creates a scope from trusted application configuration.
99    pub fn try_new(
100        application_namespace: impl Into<String>,
101        collection: CollectionName,
102    ) -> Result<Self, DocumentRecoveryError> {
103        let application_namespace = application_namespace.into();
104        if !valid_portable_label(&application_namespace, MAX_APPLICATION_NAMESPACE_BYTES) {
105            return Err(DocumentRecoveryError::InvalidBinding);
106        }
107        Ok(Self {
108            application_namespace,
109            collection,
110        })
111    }
112
113    /// Returns the trusted application namespace.
114    pub fn application_namespace(&self) -> &str {
115        &self.application_namespace
116    }
117
118    /// Returns the collection authenticated by the snapshot.
119    pub fn collection(&self) -> &CollectionName {
120        &self.collection
121    }
122}
123
124/// Explicit memory and pagination limits for one recovery operation.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub struct DocumentRecoveryPolicy {
127    page_size: u32,
128    max_documents: u32,
129    max_snapshot_bytes: usize,
130}
131
132impl DocumentRecoveryPolicy {
133    /// Validates policy bounds before any repository access or decoding.
134    pub fn try_new(
135        page_size: u32,
136        max_documents: u32,
137        max_snapshot_bytes: usize,
138    ) -> Result<Self, DocumentRecoveryError> {
139        if page_size == 0
140            || page_size > 500
141            || max_documents == 0
142            || max_documents > MAX_DOCUMENTS
143            || !(MIN_SNAPSHOT_BYTES..=MAX_SNAPSHOT_BYTES).contains(&max_snapshot_bytes)
144        {
145            return Err(DocumentRecoveryError::InvalidPolicy);
146        }
147        Ok(Self {
148            page_size,
149            max_documents,
150            max_snapshot_bytes,
151        })
152    }
153
154    /// Returns the per-query document ceiling.
155    pub const fn page_size(self) -> u32 {
156        self.page_size
157    }
158
159    /// Returns the total document ceiling.
160    pub const fn max_documents(self) -> u32 {
161        self.max_documents
162    }
163
164    /// Returns the authenticated plaintext byte ceiling.
165    pub const fn max_snapshot_bytes(self) -> usize {
166        self.max_snapshot_bytes
167    }
168}
169
170impl Default for DocumentRecoveryPolicy {
171    fn default() -> Self {
172        Self {
173            page_size: 100,
174            max_documents: 10_000,
175            max_snapshot_bytes: 16 * 1024 * 1024,
176        }
177    }
178}
179
180/// Named AES-256-GCM key used to encrypt and authenticate snapshots.
181pub struct DocumentRecoveryKey {
182    key_id: String,
183    cipher: Aes256Gcm,
184}
185
186impl DocumentRecoveryKey {
187    /// Builds a key from 256 bits supplied by a CSPRNG or secret manager.
188    pub fn try_new(
189        key_id: impl Into<String>,
190        key: [u8; 32],
191    ) -> Result<Self, DocumentRecoveryError> {
192        let key_id = key_id.into();
193        if !valid_portable_label(&key_id, MAX_KEY_ID_BYTES) {
194            return Err(DocumentRecoveryError::InvalidKey);
195        }
196        let key = Zeroizing::new(key);
197        let cipher = Aes256Gcm::new_from_slice(key.as_ref())
198            .map_err(|_| DocumentRecoveryError::EncryptionFailed)?;
199        Ok(Self { key_id, cipher })
200    }
201
202    /// Returns the non-secret key-rotation identifier.
203    pub fn key_id(&self) -> &str {
204        &self.key_id
205    }
206}
207
208impl fmt::Debug for DocumentRecoveryKey {
209    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
210        formatter
211            .debug_struct("DocumentRecoveryKey")
212            .field("key_id", &self.key_id)
213            .field("key", &"[REDACTED]")
214            .finish()
215    }
216}
217
218/// Opaque encrypted snapshot suitable for application-owned durable storage.
219#[derive(Clone, PartialEq, Eq)]
220pub struct EncryptedDocumentSnapshot(String);
221
222impl EncryptedDocumentSnapshot {
223    /// Revalidates an envelope loaded from storage before retaining it.
224    pub fn try_from_envelope(
225        envelope: impl Into<String>,
226        policy: DocumentRecoveryPolicy,
227    ) -> Result<Self, DocumentRecoveryError> {
228        let envelope = envelope.into();
229        codec::validate_envelope(&envelope, policy)?;
230        Ok(Self(envelope))
231    }
232
233    /// Returns the opaque envelope at an explicit persistence boundary.
234    pub fn as_str(&self) -> &str {
235        &self.0
236    }
237}
238
239impl fmt::Debug for EncryptedDocumentSnapshot {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        formatter.write_str("EncryptedDocumentSnapshot([REDACTED])")
242    }
243}
244
245/// Counts from one verified restoration attempt.
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub struct DocumentRecoveryReport {
248    inserted: u32,
249    replayed: u32,
250    verified: u32,
251}
252
253impl DocumentRecoveryReport {
254    /// Returns documents newly inserted during this attempt.
255    pub const fn inserted(self) -> u32 {
256        self.inserted
257    }
258
259    /// Returns matching documents already present or won by a concurrent retry.
260    pub const fn replayed(self) -> u32 {
261        self.replayed
262    }
263
264    /// Returns the final exact inventory size.
265    pub const fn verified(self) -> u32 {
266        self.verified
267    }
268}
269
270fn valid_portable_label(value: &str, max_bytes: usize) -> bool {
271    !value.is_empty()
272        && value.len() <= max_bytes
273        && value
274            .bytes()
275            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
276}
277
278pub use operation::{export_document_snapshot, restore_document_snapshot};