Skip to main content

appcore_storage/
storage_dnt.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: storage_dnt.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/02 00:04:12 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/02 12:07:11 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! DNT-backed sealed storage adapters.
12
13use super::{FileStorageProvider, StorageError, StorageResult};
14use appcore_contracts::ApplicationId;
15use appcore_dnt::{
16    inspect_header, open_owned, rekey, seal, verify, ContentType, DntCodec, DntKeyProvider,
17    DntOpenOptions, DntSealOptions, KeyId, DNT_FLAG_PAYLOAD_DEFLATE,
18};
19use appcore_types::TenantId;
20
21/// Storage policy used when sealing objects into DNT envelopes.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SealedStoragePolicy {
24    /// Application that owns the object.
25    pub application_id: ApplicationId,
26    /// Optional tenant boundary.
27    pub tenant_id: Option<TenantId>,
28    /// Logical DNT content type.
29    pub content_type: ContentType,
30    /// Payload schema version.
31    pub schema_version: u32,
32    /// Key used for new writes.
33    pub key_id: KeyId,
34    /// Maximum encoded payload size.
35    pub max_payload_bytes: u64,
36    /// Authenticated public metadata.
37    pub public_metadata: Vec<u8>,
38}
39
40impl SealedStoragePolicy {
41    /// Creates DNT open options from this policy.
42    pub fn open_options(&self) -> DntOpenOptions {
43        DntOpenOptions {
44            application_id: self.application_id.clone(),
45            tenant_id: self.tenant_id.clone(),
46            content_type: self.content_type.clone(),
47            max_payload_bytes: Some(self.max_payload_bytes),
48        }
49    }
50
51    fn seal_options(&self, created_at_ms: u64, encrypted_metadata: Vec<u8>) -> DntSealOptions {
52        DntSealOptions {
53            application_id: self.application_id.clone(),
54            tenant_id: self.tenant_id.clone(),
55            content_type: self.content_type.clone(),
56            schema_version: self.schema_version,
57            key_id: self.key_id.clone(),
58            created_at_ms,
59            public_metadata: self.public_metadata.clone(),
60            encrypted_metadata,
61            flags: 0,
62            max_payload_bytes: Some(self.max_payload_bytes),
63        }
64    }
65}
66
67/// Generic sealed-object store contract.
68pub trait SealedObjectStore {
69    /// Seals and atomically writes an object.
70    fn write_object(&self, path: &str, payload: &[u8]) -> StorageResult<()>;
71    /// Reads, authenticates and opens an object.
72    fn read_object(&self, path: &str) -> StorageResult<Vec<u8>>;
73    /// Cryptographically verifies an object without returning plaintext.
74    fn verify_object(&self, path: &str) -> StorageResult<()>;
75    /// Re-encrypts an object under a new key.
76    fn rekey_object(&self, path: &str, new_key_id: KeyId) -> StorageResult<()>;
77}
78
79/// Sealed snapshot store contract.
80pub trait SealedSnapshotStore: SealedObjectStore {}
81
82/// Sealed secret store contract.
83pub trait SealedSecretStore: SealedObjectStore {}
84
85/// DNT adapter over the local file storage provider.
86#[derive(Debug)]
87pub struct DntFileObjectStore<'a, K, C> {
88    provider: &'a FileStorageProvider,
89    key_provider: &'a K,
90    codec: C,
91    policy: SealedStoragePolicy,
92}
93
94impl<'a, K, C> DntFileObjectStore<'a, K, C>
95where
96    K: DntKeyProvider,
97    C: DntCodec,
98{
99    /// Creates a sealed object store over an existing file provider.
100    pub fn new(
101        provider: &'a FileStorageProvider,
102        key_provider: &'a K,
103        codec: C,
104        policy: SealedStoragePolicy,
105    ) -> Self {
106        Self {
107            provider,
108            key_provider,
109            codec,
110            policy,
111        }
112    }
113
114    /// Seals, compacts and atomically writes an object.
115    ///
116    /// Compact writes use DNT's authenticated DEFLATE payload flag. Existing
117    /// readers open compact and normal envelopes through the same `read_object`
118    /// path as long as they provide a payload bound.
119    pub fn write_object_compact(&self, path: &str, payload: &[u8]) -> StorageResult<()> {
120        self.write_object_with_flags(path, payload, DNT_FLAG_PAYLOAD_DEFLATE)
121    }
122
123    fn write_object_with_flags(&self, path: &str, payload: &[u8], flags: u32) -> StorageResult<()> {
124        let mut options = self.policy.seal_options(now_ms(), Vec::new());
125        options.flags = flags;
126        let envelope =
127            seal(payload, self.key_provider, &self.codec, options).map_err(map_dnt_error)?;
128        verify(
129            &envelope,
130            self.key_provider,
131            &self.codec,
132            &self.policy.open_options(),
133        )
134        .map_err(map_dnt_error)?;
135        self.provider.write_bytes_atomic(path, &envelope)
136    }
137}
138
139impl<K, C> SealedObjectStore for DntFileObjectStore<'_, K, C>
140where
141    K: DntKeyProvider,
142    C: DntCodec,
143{
144    fn write_object(&self, path: &str, payload: &[u8]) -> StorageResult<()> {
145        self.write_object_with_flags(path, payload, 0)
146    }
147
148    fn read_object(&self, path: &str) -> StorageResult<Vec<u8>> {
149        let options = self.policy.open_options();
150        let max_envelope_bytes = options.max_envelope_bytes().map_err(map_dnt_error)?;
151        let bytes = self.provider.read_bytes_bounded(path, max_envelope_bytes)?;
152        let opened =
153            open_owned(bytes, self.key_provider, &self.codec, &options).map_err(map_dnt_error)?;
154        Ok(opened.payload)
155    }
156
157    fn verify_object(&self, path: &str) -> StorageResult<()> {
158        let options = self.policy.open_options();
159        let max_envelope_bytes = options.max_envelope_bytes().map_err(map_dnt_error)?;
160        let bytes = self.provider.read_bytes_bounded(path, max_envelope_bytes)?;
161        verify(&bytes, self.key_provider, &self.codec, &options).map_err(map_dnt_error)?;
162        Ok(())
163    }
164
165    fn rekey_object(&self, path: &str, new_key_id: KeyId) -> StorageResult<()> {
166        let options = self.policy.open_options();
167        let max_envelope_bytes = options.max_envelope_bytes().map_err(map_dnt_error)?;
168        let bytes = self.provider.read_bytes_bounded(path, max_envelope_bytes)?;
169        inspect_header(&bytes).map_err(map_dnt_error)?;
170        let rotated = rekey(&bytes, self.key_provider, &self.codec, &options, new_key_id)
171            .map_err(map_dnt_error)?;
172        verify(&rotated, self.key_provider, &self.codec, &options).map_err(map_dnt_error)?;
173        self.provider.write_bytes_atomic(path, &rotated)
174    }
175}
176
177/// DNT file adapter for snapshots.
178#[derive(Debug)]
179pub struct DntFileSnapshotStore<'a, K, C>(DntFileObjectStore<'a, K, C>);
180
181impl<'a, K, C> DntFileSnapshotStore<'a, K, C>
182where
183    K: DntKeyProvider,
184    C: DntCodec,
185{
186    /// Creates a sealed snapshot store.
187    pub fn new(inner: DntFileObjectStore<'a, K, C>) -> Self {
188        Self(inner)
189    }
190}
191
192impl<K, C> SealedObjectStore for DntFileSnapshotStore<'_, K, C>
193where
194    K: DntKeyProvider,
195    C: DntCodec,
196{
197    fn write_object(&self, path: &str, payload: &[u8]) -> StorageResult<()> {
198        self.0.write_object(path, payload)
199    }
200
201    fn read_object(&self, path: &str) -> StorageResult<Vec<u8>> {
202        self.0.read_object(path)
203    }
204
205    fn verify_object(&self, path: &str) -> StorageResult<()> {
206        self.0.verify_object(path)
207    }
208
209    fn rekey_object(&self, path: &str, new_key_id: KeyId) -> StorageResult<()> {
210        self.0.rekey_object(path, new_key_id)
211    }
212}
213
214impl<K, C> SealedSnapshotStore for DntFileSnapshotStore<'_, K, C>
215where
216    K: DntKeyProvider,
217    C: DntCodec,
218{
219}
220
221/// DNT file adapter for local secrets.
222#[derive(Debug)]
223pub struct DntFileSecretStore<'a, K, C>(DntFileObjectStore<'a, K, C>);
224
225impl<'a, K, C> DntFileSecretStore<'a, K, C>
226where
227    K: DntKeyProvider,
228    C: DntCodec,
229{
230    /// Creates a sealed secret store.
231    pub fn new(inner: DntFileObjectStore<'a, K, C>) -> Self {
232        Self(inner)
233    }
234}
235
236impl<K, C> SealedObjectStore for DntFileSecretStore<'_, K, C>
237where
238    K: DntKeyProvider,
239    C: DntCodec,
240{
241    fn write_object(&self, path: &str, payload: &[u8]) -> StorageResult<()> {
242        self.0.write_object(path, payload)
243    }
244
245    fn read_object(&self, path: &str) -> StorageResult<Vec<u8>> {
246        self.0.read_object(path)
247    }
248
249    fn verify_object(&self, path: &str) -> StorageResult<()> {
250        self.0.verify_object(path)
251    }
252
253    fn rekey_object(&self, path: &str, new_key_id: KeyId) -> StorageResult<()> {
254        self.0.rekey_object(path, new_key_id)
255    }
256}
257
258impl<K, C> SealedSecretStore for DntFileSecretStore<'_, K, C>
259where
260    K: DntKeyProvider,
261    C: DntCodec,
262{
263}
264
265fn now_ms() -> u64 {
266    std::time::SystemTime::now()
267        .duration_since(std::time::UNIX_EPOCH)
268        .map(|duration| duration.as_millis() as u64)
269        .unwrap_or(0)
270}
271
272fn map_dnt_error(error: appcore_dnt::DntError) -> StorageError {
273    match error {
274        appcore_dnt::DntError::Io => StorageError::TransactionFailed("dnt".to_string()),
275        appcore_dnt::DntError::KeyUnavailable
276        | appcore_dnt::DntError::AuthenticationFailed
277        | appcore_dnt::DntError::ContextMismatch => StorageError::SecurityFailed("dnt".to_string()),
278        _ => StorageError::InvalidPath("dnt".to_string()),
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use appcore_dnt::{
286        BytesCodec, SecretKey, StaticDntKeyProvider, DNT_CONTENT_OCTET_STREAM, DNT_CONTENT_SECRET,
287    };
288
289    fn provider() -> StaticDntKeyProvider {
290        StaticDntKeyProvider::new()
291            .with_key(KeyId::new("key-a").unwrap(), SecretKey::new([3; 32]))
292            .with_key(KeyId::new("key-b").unwrap(), SecretKey::new([4; 32]))
293    }
294
295    fn storage() -> FileStorageProvider {
296        let root = std::env::temp_dir().join(format!("appcore-storage-dnt-{}", unique()));
297        let data = root.join("data");
298        let backups = root.join("backups");
299        let provider = FileStorageProvider::new(data, backups);
300        provider.create_dirs().unwrap();
301        provider
302    }
303
304    fn policy(content_type: &str) -> SealedStoragePolicy {
305        SealedStoragePolicy {
306            application_id: ApplicationId::new("app-a").unwrap(),
307            tenant_id: Some(TenantId::new("tenant-a").unwrap()),
308            content_type: ContentType::new(content_type).unwrap(),
309            schema_version: 1,
310            key_id: KeyId::new("key-a").unwrap(),
311            max_payload_bytes: 1024 * 1024,
312            public_metadata: b"store=sealed".to_vec(),
313        }
314    }
315
316    #[test]
317    fn sealed_object_store_keeps_plaintext_off_disk() {
318        let storage = storage();
319        let keys = provider();
320        let store = DntFileObjectStore::new(
321            &storage,
322            &keys,
323            BytesCodec,
324            policy(DNT_CONTENT_OCTET_STREAM),
325        );
326
327        store
328            .write_object("objects/a.dntb", b"secret payload")
329            .unwrap();
330        let raw = storage.read_bytes("objects/a.dntb").unwrap();
331
332        assert!(!raw
333            .windows(b"secret payload".len())
334            .any(|w| w == b"secret payload"));
335        assert_eq!(
336            store.read_object("objects/a.dntb").unwrap(),
337            b"secret payload"
338        );
339        assert_eq!(store.verify_object("objects/a.dntb"), Ok(()));
340    }
341
342    #[test]
343    fn sealed_object_store_can_write_compact_dnt() {
344        let storage = storage();
345        let keys = provider();
346        let store = DntFileObjectStore::new(
347            &storage,
348            &keys,
349            BytesCodec,
350            policy(DNT_CONTENT_OCTET_STREAM),
351        );
352        let payload = b"snapshot-line=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n".repeat(2048);
353
354        store.write_object("objects/normal.dntb", &payload).unwrap();
355        store
356            .write_object_compact("objects/compact.dntb", &payload)
357            .unwrap();
358        let normal = storage.read_bytes("objects/normal.dntb").unwrap();
359        let compact = storage.read_bytes("objects/compact.dntb").unwrap();
360
361        assert!(compact.len() < normal.len() / 10);
362        assert_eq!(store.read_object("objects/compact.dntb").unwrap(), payload);
363        assert_eq!(store.verify_object("objects/compact.dntb"), Ok(()));
364    }
365
366    #[test]
367    fn sealed_object_rekey_preserves_payload() {
368        let storage = storage();
369        let keys = provider();
370        let store = DntFileObjectStore::new(
371            &storage,
372            &keys,
373            BytesCodec,
374            policy(DNT_CONTENT_OCTET_STREAM),
375        );
376
377        store.write_object("objects/a.dntb", b"payload").unwrap();
378        store
379            .rekey_object("objects/a.dntb", KeyId::new("key-b").unwrap())
380            .unwrap();
381
382        let raw = storage.read_bytes("objects/a.dntb").unwrap();
383        assert_eq!(inspect_header(&raw).unwrap().key_id.as_str(), "key-b");
384        assert_eq!(store.read_object("objects/a.dntb").unwrap(), b"payload");
385    }
386
387    #[test]
388    fn sealed_object_rejects_oversized_envelope_before_opening() {
389        let storage = storage();
390        let keys = provider();
391        let policy = policy(DNT_CONTENT_OCTET_STREAM);
392        let max_envelope = policy.open_options().max_envelope_bytes().unwrap();
393        let store = DntFileObjectStore::new(&storage, &keys, BytesCodec, policy);
394        storage
395            .write_bytes_atomic(
396                "objects/oversized.dntb",
397                &vec![0; max_envelope as usize + 1],
398            )
399            .unwrap();
400
401        assert!(matches!(
402            store.read_object("objects/oversized.dntb"),
403            Err(StorageError::TransactionFailed(_))
404        ));
405        assert!(matches!(
406            store.verify_object("objects/oversized.dntb"),
407            Err(StorageError::TransactionFailed(_))
408        ));
409    }
410
411    #[test]
412    fn sealed_secret_store_rejects_wrong_content_type() {
413        let storage = storage();
414        let keys = provider();
415        let object_store = DntFileObjectStore::new(
416            &storage,
417            &keys,
418            BytesCodec,
419            policy(DNT_CONTENT_OCTET_STREAM),
420        );
421        object_store
422            .write_object("secrets/a.dnt", b"payload")
423            .unwrap();
424
425        let secret_store =
426            DntFileObjectStore::new(&storage, &keys, BytesCodec, policy(DNT_CONTENT_SECRET));
427        assert!(matches!(
428            secret_store.read_object("secrets/a.dnt"),
429            Err(StorageError::SecurityFailed(_))
430        ));
431    }
432
433    fn unique() -> u64 {
434        // appcore-norm: allow(global-state) reason: atomic sequence prevents process-local temporary path collisions
435        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
436        let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
437        ((std::process::id() as u64) << 32) | count
438    }
439}