rullst_orm/polyglot/
recovery.rs1mod 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#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum DocumentRecoveryError {
31 #[error("document recovery key must have a portable ID and exactly 32 bytes")]
33 InvalidKey,
34 #[error("document recovery application namespace is invalid")]
36 InvalidBinding,
37 #[error("document recovery policy is invalid")]
39 InvalidPolicy,
40 #[error("document recovery envelope is malformed or oversized")]
42 InvalidEnvelope,
43 #[error("document recovery envelope version is unsupported")]
45 UnsupportedVersion,
46 #[error("document recovery snapshot requires a different key ID")]
48 KeyIdMismatch,
49 #[error("operating-system randomness is unavailable")]
51 RandomnessUnavailable,
52 #[error("document recovery snapshot encryption failed")]
54 EncryptionFailed,
55 #[error("document recovery snapshot authentication failed")]
57 AuthenticationFailed,
58 #[error("document recovery snapshot payload is invalid")]
60 InvalidPayload,
61 #[error("document recovery requires JSON-object models without id or _id fields")]
63 NonPortableModel,
64 #[error("document recovery capacity was exceeded")]
66 CapacityExceeded,
67 #[error("document recovery inventory was not strictly ordered")]
69 InvalidInventory,
70 #[error("document recovery source changed during export")]
72 SourceChanged,
73 #[error("document recovery destination conflicts with the snapshot")]
75 DestinationConflict,
76 #[error("document recovery destination changed during restoration")]
78 DestinationChanged,
79 #[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#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct DocumentRecoveryBinding {
93 application_namespace: String,
94 collection: CollectionName,
95}
96
97impl DocumentRecoveryBinding {
98 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 pub fn application_namespace(&self) -> &str {
115 &self.application_namespace
116 }
117
118 pub fn collection(&self) -> &CollectionName {
120 &self.collection
121 }
122}
123
124#[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 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 pub const fn page_size(self) -> u32 {
156 self.page_size
157 }
158
159 pub const fn max_documents(self) -> u32 {
161 self.max_documents
162 }
163
164 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
180pub struct DocumentRecoveryKey {
182 key_id: String,
183 cipher: Aes256Gcm,
184}
185
186impl DocumentRecoveryKey {
187 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 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#[derive(Clone, PartialEq, Eq)]
220pub struct EncryptedDocumentSnapshot(String);
221
222impl EncryptedDocumentSnapshot {
223 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub struct DocumentRecoveryReport {
248 inserted: u32,
249 replayed: u32,
250 verified: u32,
251}
252
253impl DocumentRecoveryReport {
254 pub const fn inserted(self) -> u32 {
256 self.inserted
257 }
258
259 pub const fn replayed(self) -> u32 {
261 self.replayed
262 }
263
264 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};