zoe_app_primitives/
file.rs

1//! File storage primitives for Zoe applications
2//!
3//! This module contains types for describing stored files that have been
4//! encrypted and stored in blob storage systems.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8// Re-export encryption types from zoe-encrypted-storage for convenience
9pub use zoe_encrypted_storage::{CompressionConfig, ConvergentEncryptionInfo};
10
11use crate::Metadata;
12
13pub mod image;
14
15pub use image::Image;
16
17/// Reference to a stored file, containing everything needed to retrieve it
18///
19/// This type represents metadata for files that have been encrypted using
20/// convergent encryption and stored in a content-addressable blob store.
21/// It contains all the information needed to retrieve and decrypt the file later.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct FileRef {
24    /// Hash of the encrypted blob in storage
25    ///
26    /// This is the content-addressable hash used by the blob storage system
27    /// to uniquely identify and retrieve the encrypted file data.
28    pub blob_hash: String,
29
30    /// Encryption metadata needed for decryption
31    ///
32    /// Contains the encryption key, compression settings, and other metadata
33    /// required to decrypt the stored file back to its original form.
34    pub encryption_info: ConvergentEncryptionInfo,
35
36    /// Original filename (for reference)
37    ///
38    /// The name of the file when it was stored. This is kept for
39    /// reference and display purposes and doesn't affect retrieval.
40    /// This is optional to support cases where filename is not relevant.
41    pub filename: Option<String>,
42
43    /// MIME type or file extension for reference
44    ///
45    /// Optional content type information derived from the file extension
46    /// or explicitly provided when storing the file.
47    pub content_type: Option<String>,
48
49    /// Additional metadata about the stored file
50    ///
51    /// Structured metadata that applications can use to store additional
52    /// information about the file (e.g., original timestamps, user tags,
53    /// categories, etc.) using typed metadata variants.
54    pub metadata: Vec<Metadata>,
55}
56
57impl FileRef {
58    /// Create a new FileRef with minimal required fields
59    pub fn new(
60        blob_hash: String,
61        encryption_info: ConvergentEncryptionInfo,
62        filename: Option<String>,
63    ) -> Self {
64        Self {
65            blob_hash,
66            encryption_info,
67            filename,
68            content_type: None,
69            metadata: vec![],
70        }
71    }
72
73    /// Add generic metadata to the stored file info
74    pub fn with_metadata(mut self, key: String, value: String) -> Self {
75        self.metadata.push(Metadata::Generic { key, value });
76        self
77    }
78
79    /// Add structured metadata to the stored file info
80    pub fn with_structured_metadata(mut self, metadata: Metadata) -> Self {
81        self.metadata.push(metadata);
82        self
83    }
84
85    /// Get all generic metadata as a key-value map for backward compatibility
86    ///
87    /// This method extracts only the `Metadata::Generic(key, value)` entries and returns them
88    /// as a `BTreeMap<String, String>` for backward compatibility with APIs that expect
89    /// key-value metadata.
90    pub fn generic_metadata(&self) -> BTreeMap<String, String> {
91        self.metadata
92            .iter()
93            .filter_map(|meta| match meta {
94                Metadata::Generic { key, value } => Some((key.clone(), value.clone())),
95                _ => None,
96            })
97            .collect()
98    }
99
100    /// Set the content type
101    pub fn with_content_type(mut self, content_type: String) -> Self {
102        self.content_type = Some(content_type);
103        self
104    }
105
106    /// Get the filename (if available)
107    pub fn filename(&self) -> Option<&str> {
108        self.filename.as_deref()
109    }
110
111    /// Get file extension from the filename (if available)
112    pub fn file_extension(&self) -> Option<String> {
113        self.filename.as_ref().and_then(|filename| {
114            std::path::Path::new(filename)
115                .extension()
116                .and_then(|ext| ext.to_str())
117                .map(|s| s.to_string())
118        })
119    }
120
121    /// Get the original file size (from encryption info)
122    pub fn original_size(&self) -> usize {
123        self.encryption_info.source_size
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    use zoe_encrypted_storage::{CompressionConfig, ConvergentEncryptionInfo};
132
133    fn create_test_encryption_info() -> ConvergentEncryptionInfo {
134        ConvergentEncryptionInfo {
135            key: [42u8; 32],
136            was_compressed: false,
137            source_size: 1024,
138        }
139    }
140
141    fn create_test_file_ref() -> FileRef {
142        FileRef::new(
143            "test_blob_hash_123".to_string(),
144            create_test_encryption_info(),
145            Some("test_file.txt".to_string()),
146        )
147    }
148
149    #[test]
150    fn test_file_ref_new() {
151        let blob_hash = "test_hash_123".to_string();
152        let encryption_info = create_test_encryption_info();
153        let filename = Some("document.pdf".to_string());
154
155        let file_ref = FileRef::new(blob_hash.clone(), encryption_info.clone(), filename.clone());
156
157        assert_eq!(file_ref.blob_hash, blob_hash);
158        assert_eq!(file_ref.encryption_info, encryption_info);
159        assert_eq!(file_ref.filename, filename);
160        assert_eq!(file_ref.content_type, None);
161        assert!(file_ref.metadata.is_empty());
162    }
163
164    #[test]
165    fn test_file_ref_with_metadata() {
166        let file_ref = create_test_file_ref()
167            .with_metadata("author".to_string(), "alice".to_string())
168            .with_metadata("category".to_string(), "documents".to_string());
169
170        let generic_meta = file_ref.generic_metadata();
171        assert_eq!(generic_meta.get("author"), Some(&"alice".to_string()));
172        assert_eq!(generic_meta.get("category"), Some(&"documents".to_string()));
173        assert_eq!(file_ref.metadata.len(), 2);
174    }
175
176    #[test]
177    fn test_file_ref_with_content_type() {
178        let file_ref = create_test_file_ref().with_content_type("application/pdf".to_string());
179
180        assert_eq!(file_ref.content_type, Some("application/pdf".to_string()));
181    }
182
183    #[test]
184    fn test_file_ref_filename() {
185        // Test with filename
186        let file_ref_with_name = create_test_file_ref();
187        assert_eq!(file_ref_with_name.filename(), Some("test_file.txt"));
188
189        // Test without filename
190        let file_ref_no_name =
191            FileRef::new("hash".to_string(), create_test_encryption_info(), None);
192        assert_eq!(file_ref_no_name.filename(), None);
193    }
194
195    #[test]
196    fn test_file_ref_file_extension() {
197        // Test with extension
198        let file_ref_txt = FileRef::new(
199            "hash".to_string(),
200            create_test_encryption_info(),
201            Some("document.txt".to_string()),
202        );
203        assert_eq!(file_ref_txt.file_extension(), Some("txt".to_string()));
204
205        // Test with multiple dots
206        let file_ref_tar_gz = FileRef::new(
207            "hash".to_string(),
208            create_test_encryption_info(),
209            Some("archive.tar.gz".to_string()),
210        );
211        assert_eq!(file_ref_tar_gz.file_extension(), Some("gz".to_string()));
212
213        // Test without extension
214        let file_ref_no_ext = FileRef::new(
215            "hash".to_string(),
216            create_test_encryption_info(),
217            Some("README".to_string()),
218        );
219        assert_eq!(file_ref_no_ext.file_extension(), None);
220
221        // Test with no filename
222        let file_ref_no_name =
223            FileRef::new("hash".to_string(), create_test_encryption_info(), None);
224        assert_eq!(file_ref_no_name.file_extension(), None);
225
226        // Test with hidden file (starts with dot - no extension)
227        let file_ref_hidden = FileRef::new(
228            "hash".to_string(),
229            create_test_encryption_info(),
230            Some(".hidden".to_string()),
231        );
232        assert_eq!(file_ref_hidden.file_extension(), None);
233
234        // Test with hidden file that has extension
235        let file_ref_hidden_with_ext = FileRef::new(
236            "hash".to_string(),
237            create_test_encryption_info(),
238            Some(".hidden.txt".to_string()),
239        );
240        assert_eq!(
241            file_ref_hidden_with_ext.file_extension(),
242            Some("txt".to_string())
243        );
244    }
245
246    #[test]
247    fn test_file_ref_original_size() {
248        let encryption_info = ConvergentEncryptionInfo {
249            key: [0u8; 32],
250            was_compressed: false,
251            source_size: 2048,
252        };
253
254        let file_ref = FileRef::new(
255            "hash".to_string(),
256            encryption_info,
257            Some("large_file.bin".to_string()),
258        );
259
260        assert_eq!(file_ref.original_size(), 2048);
261    }
262
263    #[test]
264    fn test_file_ref_builder_pattern() {
265        let file_ref = FileRef::new(
266            "test_hash".to_string(),
267            create_test_encryption_info(),
268            Some("image.png".to_string()),
269        )
270        .with_content_type("image/png".to_string())
271        .with_metadata("width".to_string(), "1920".to_string())
272        .with_metadata("height".to_string(), "1080".to_string());
273
274        assert_eq!(file_ref.content_type, Some("image/png".to_string()));
275        let generic_meta = file_ref.generic_metadata();
276        assert_eq!(generic_meta.get("width"), Some(&"1920".to_string()));
277        assert_eq!(generic_meta.get("height"), Some(&"1080".to_string()));
278    }
279
280    #[test]
281    fn test_postcard_serialization_file_ref() {
282        let file_ref = create_test_file_ref()
283            .with_content_type("text/plain".to_string())
284            .with_metadata("test".to_string(), "value".to_string());
285
286        let serialized = postcard::to_stdvec(&file_ref).expect("Failed to serialize");
287        let deserialized: FileRef =
288            postcard::from_bytes(&serialized).expect("Failed to deserialize");
289
290        assert_eq!(file_ref, deserialized);
291    }
292
293    #[test]
294    fn test_postcard_serialization_convergent_encryption_info() {
295        let encryption_info = create_test_encryption_info();
296
297        let serialized = postcard::to_stdvec(&encryption_info).expect("Failed to serialize");
298        let deserialized: ConvergentEncryptionInfo =
299            postcard::from_bytes(&serialized).expect("Failed to deserialize");
300
301        assert_eq!(encryption_info, deserialized);
302    }
303
304    #[test]
305    fn test_postcard_serialization_compression_config() {
306        let configs = [
307            CompressionConfig::default(),
308            CompressionConfig {
309                enabled: false,
310                quality: 1,
311                min_size: 0,
312            },
313            CompressionConfig {
314                enabled: true,
315                quality: 11,
316                min_size: 1024,
317            },
318        ];
319
320        for compression in configs {
321            let serialized = postcard::to_stdvec(&compression).expect("Failed to serialize");
322            let deserialized: CompressionConfig =
323                postcard::from_bytes(&serialized).expect("Failed to deserialize");
324            assert_eq!(compression.enabled, deserialized.enabled);
325            assert_eq!(compression.quality, deserialized.quality);
326            assert_eq!(compression.min_size, deserialized.min_size);
327        }
328    }
329
330    #[test]
331    fn test_file_ref_with_different_compression_states() {
332        // Test with compressed file
333        let encryption_info_compressed = ConvergentEncryptionInfo {
334            key: [1u8; 32],
335            was_compressed: true,
336            source_size: 512,
337        };
338
339        let file_ref_compressed = FileRef::new(
340            "compressed_hash".to_string(),
341            encryption_info_compressed,
342            Some("compressed.txt".to_string()),
343        );
344
345        assert_eq!(file_ref_compressed.original_size(), 512);
346        assert!(file_ref_compressed.encryption_info.was_compressed);
347
348        // Test with uncompressed file
349        let encryption_info_uncompressed = ConvergentEncryptionInfo {
350            key: [2u8; 32],
351            was_compressed: false,
352            source_size: 1024,
353        };
354
355        let file_ref_uncompressed = FileRef::new(
356            "uncompressed_hash".to_string(),
357            encryption_info_uncompressed,
358            Some("uncompressed.bin".to_string()),
359        );
360
361        assert_eq!(file_ref_uncompressed.original_size(), 1024);
362        assert!(!file_ref_uncompressed.encryption_info.was_compressed);
363    }
364
365    #[test]
366    fn test_file_ref_metadata_operations() {
367        use crate::Metadata;
368
369        let metadata = vec![Metadata::Generic {
370            key: "initial".to_string(),
371            value: "value".to_string(),
372        }];
373
374        let file_ref = FileRef {
375            blob_hash: "test".to_string(),
376            encryption_info: create_test_encryption_info(),
377            filename: None,
378            content_type: None,
379            metadata,
380        };
381
382        // Test existing metadata
383        let generic_meta = file_ref.generic_metadata();
384        assert_eq!(generic_meta.get("initial"), Some(&"value".to_string()));
385
386        // Test adding more metadata via builder
387        let updated_file_ref =
388            file_ref.with_metadata("new_key".to_string(), "new_value".to_string());
389
390        let updated_generic_meta = updated_file_ref.generic_metadata();
391        assert_eq!(
392            updated_generic_meta.get("initial"),
393            Some(&"value".to_string())
394        );
395        assert_eq!(
396            updated_generic_meta.get("new_key"),
397            Some(&"new_value".to_string())
398        );
399    }
400}