tiny_counter/storage/
file_per_event.rs

1use crate::{Error, Result, Storage};
2use std::path::{Path, PathBuf};
3
4pub struct FilePerEvent {
5    directory: PathBuf,
6    extension: String,
7}
8
9impl FilePerEvent {
10    pub fn new(directory: impl AsRef<Path>, extension: impl Into<String>) -> Result<Self> {
11        let directory = directory.as_ref().to_path_buf();
12        let extension = extension.into();
13
14        // Create directory if it doesn't exist
15        if !directory.exists() {
16            std::fs::create_dir_all(&directory)
17                .map_err(|e| Error::Storage(format!("Failed to create directory: {}", e)))?;
18        }
19
20        Ok(Self {
21            directory,
22            extension,
23        })
24    }
25}
26
27impl Storage for FilePerEvent {
28    fn save(&mut self, key: &str, data: Vec<u8>) -> Result<()> {
29        let path = self.key_to_filename(key)?;
30
31        // Use atomic write-temp-then-rename pattern for crash safety
32        // Generate a unique temporary file name using thread ID and nanos
33        let temp_path = path.with_extension(format!(
34            "tmp.{}.{}",
35            std::process::id(),
36            std::time::SystemTime::now()
37                .duration_since(std::time::UNIX_EPOCH)
38                .unwrap()
39                .as_nanos()
40        ));
41
42        // Write to temporary file first
43        if let Err(e) = std::fs::write(&temp_path, data) {
44            // Clean up temp file if it was created
45            let _ = std::fs::remove_file(&temp_path);
46            return Err(Error::Storage(format!("Failed to write file: {}", e)));
47        }
48
49        // Atomically rename temp to final path
50        std::fs::rename(&temp_path, &path).map_err(|e| {
51            // Clean up temp file on rename failure
52            let _ = std::fs::remove_file(&temp_path);
53            Error::Storage(format!("Failed to rename temp file: {}", e))
54        })?;
55
56        Ok(())
57    }
58
59    fn load(&self, key: &str) -> Result<Option<Vec<u8>>> {
60        let path = self.key_to_filename(key)?;
61
62        if !path.exists() {
63            return Ok(None);
64        }
65
66        let data = std::fs::read(&path)
67            .map_err(|e| Error::Storage(format!("Failed to read file: {}", e)))?;
68        Ok(Some(data))
69    }
70
71    fn delete(&mut self, key: &str) -> Result<()> {
72        let path = self.key_to_filename(key)?;
73
74        if path.exists() {
75            std::fs::remove_file(&path)
76                .map_err(|e| Error::Storage(format!("Failed to delete file: {}", e)))?;
77        }
78
79        Ok(())
80    }
81
82    fn list_keys(&self) -> Result<Vec<String>> {
83        let mut keys = Vec::new();
84
85        let entries = std::fs::read_dir(&self.directory)
86            .map_err(|e| Error::Storage(format!("Failed to read directory: {}", e)))?;
87
88        for entry in entries {
89            let entry = entry
90                .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
91            let path = entry.path();
92
93            // Skip if not a file
94            if !path.is_file() {
95                continue;
96            }
97
98            // Extract filename without extension
99            if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
100                // Decode the URL-encoded filename
101                let key = Self::decode_key(filename)?;
102                keys.push(key);
103            }
104        }
105
106        Ok(keys)
107    }
108}
109
110impl FilePerEvent {
111    fn encode_key(key: &str) -> String {
112        let mut result = String::new();
113        for c in key.chars() {
114            match c {
115                'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
116                    result.push(c);
117                }
118                _ => {
119                    let mut buf = [0u8; 4];
120                    let bytes = c.encode_utf8(&mut buf).as_bytes();
121                    for &byte in bytes {
122                        result.push_str(&format!("%{:02X}", byte));
123                    }
124                }
125            }
126        }
127        result
128    }
129
130    fn decode_key(encoded: &str) -> Result<String> {
131        let mut bytes = Vec::new();
132        let mut chars = encoded.chars();
133
134        while let Some(c) = chars.next() {
135            if c == '%' {
136                let hex: String = chars.by_ref().take(2).collect();
137                if hex.len() != 2 {
138                    return Err(Error::Storage(format!(
139                        "Invalid percent encoding: %{}",
140                        hex
141                    )));
142                }
143                let byte = u8::from_str_radix(&hex, 16)
144                    .map_err(|e| Error::Storage(format!("Invalid hex in encoding: {}", e)))?;
145                bytes.push(byte);
146            } else {
147                // Non-encoded ASCII character
148                for byte in c.to_string().as_bytes() {
149                    bytes.push(*byte);
150                }
151            }
152        }
153
154        String::from_utf8(bytes)
155            .map_err(|e| Error::Storage(format!("Invalid UTF-8 in decoded key: {}", e)))
156    }
157
158    fn key_to_filename(&self, key: &str) -> Result<PathBuf> {
159        // Validation 1: Reject keys containing path traversal sequences
160        // Check for ".", "..", and any sequences that could be path traversal attempts
161        // Note: "/" and "\" are now allowed in keys - they will be URL-encoded to %2F and %5C,
162        // making them safe for filesystem use. However, we still need to prevent ".." sequences
163        // which could be used for traversal even after encoding.
164        if key == "."
165            || key == ".."
166            || key.starts_with("../")
167            || key.starts_with("..\\")
168            || key.contains("/..")
169            || key.contains("\\..")
170        {
171            return Err(Error::Storage(
172                "Invalid key: key cannot contain '.' or '..' path segments".to_string(),
173            ));
174        }
175
176        // Encode the key for safe filesystem use
177        // This converts "/" to "%2F" and "\" to "%5C", among other transformations
178        let encoded = Self::encode_key(key);
179
180        // Build the path
181        let filename = format!("{}{}", encoded, self.extension);
182        let path = self.directory.join(&filename);
183
184        // Validation 2: Canonicalize both paths and verify the result is inside directory
185        // This protects against symlink attacks and other path traversal attempts.
186        // Since URL encoding prevents "/" and "\" from being interpreted as path separators,
187        // this check ensures that even encoded characters cannot escape the directory.
188        let canonical_dir = self
189            .directory
190            .canonicalize()
191            .map_err(|e| Error::Storage(format!("Failed to canonicalize directory: {}", e)))?;
192
193        let canonical_path = match path.canonicalize() {
194            Ok(p) => p,
195            Err(_) => {
196                // If file doesn't exist yet, canonicalize the parent and append the filename
197                let parent = path
198                    .parent()
199                    .ok_or_else(|| Error::Storage("Path has no parent".to_string()))?;
200                let parent_canonical = parent
201                    .canonicalize()
202                    .map_err(|e| Error::Storage(format!("Failed to canonicalize parent: {}", e)))?;
203                parent_canonical.join(
204                    path.file_name()
205                        .ok_or_else(|| Error::Storage("Path has no filename".to_string()))?,
206                )
207            }
208        };
209
210        // Verify the canonical path is inside the canonical directory
211        if !canonical_path.starts_with(&canonical_dir) {
212            return Err(Error::Storage(
213                "Invalid key: path escapes intended directory".to_string(),
214            ));
215        }
216
217        Ok(path)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use tempfile::TempDir;
225
226    fn temp_dir() -> TempDir {
227        TempDir::new().unwrap()
228    }
229
230    #[test]
231    fn test_encode_simple_key() {
232        assert_eq!(FilePerEvent::encode_key("simple"), "simple");
233    }
234
235    #[test]
236    fn test_encode_key_with_colon() {
237        assert_eq!(FilePerEvent::encode_key("user:123"), "user%3A123");
238    }
239
240    #[test]
241    fn test_encode_key_with_slash() {
242        assert_eq!(FilePerEvent::encode_key("api/endpoint"), "api%2Fendpoint");
243    }
244
245    #[test]
246    fn test_encode_decode_roundtrip() {
247        let keys = vec![
248            "simple",
249            "user:123",
250            "api/endpoint",
251            "key with spaces",
252            "special!@#$chars",
253        ];
254
255        for key in keys {
256            let encoded = FilePerEvent::encode_key(key);
257            let decoded = FilePerEvent::decode_key(&encoded).unwrap();
258            assert_eq!(decoded, key);
259        }
260    }
261
262    #[test]
263    fn test_encode_unicode_roundtrip() {
264        let keys = vec!["emoji👍test", "中文测试", "Ñoño", "café☕"];
265        for key in keys {
266            let encoded = FilePerEvent::encode_key(key);
267            let decoded = FilePerEvent::decode_key(&encoded).unwrap();
268            assert_eq!(decoded, key, "Failed for key: {}", key);
269        }
270    }
271
272    #[test]
273    fn test_new_creates_directory_if_missing() {
274        let dir = temp_dir();
275        let storage_dir = dir.path().join("events");
276        assert!(!storage_dir.exists());
277
278        let _storage = FilePerEvent::new(&storage_dir, ".dat").unwrap();
279        assert!(storage_dir.exists());
280    }
281
282    #[test]
283    fn test_save_and_load_roundtrip() {
284        let dir = temp_dir();
285        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
286
287        let data = vec![1, 2, 3, 4, 5];
288        storage.save("test_key", data.clone()).unwrap();
289
290        let loaded = storage.load("test_key").unwrap();
291        assert_eq!(loaded, Some(data));
292    }
293
294    #[test]
295    fn test_load_nonexistent_returns_none() {
296        let dir = temp_dir();
297        let storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
298
299        let loaded = storage.load("nonexistent").unwrap();
300        assert_eq!(loaded, None);
301    }
302
303    #[test]
304    fn test_delete_removes_file() {
305        let dir = temp_dir();
306        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
307
308        storage.save("test", vec![1, 2, 3]).unwrap();
309        storage.delete("test").unwrap();
310
311        let loaded = storage.load("test").unwrap();
312        assert_eq!(loaded, None);
313    }
314
315    #[test]
316    fn test_list_keys_returns_all_keys() {
317        let dir = temp_dir();
318        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
319
320        storage.save("key1", vec![1]).unwrap();
321        storage.save("key2", vec![2]).unwrap();
322        storage.save("key3", vec![3]).unwrap();
323
324        let mut keys = storage.list_keys().unwrap();
325        keys.sort();
326        assert_eq!(keys, vec!["key1", "key2", "key3"]);
327    }
328
329    #[test]
330    fn test_keys_with_special_characters() {
331        let dir = temp_dir();
332        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
333
334        let special_keys = vec![
335            "user:123",
336            "event:type:subtype",
337            "key with spaces",
338            "special!@#$%^&*()chars",
339            "api/endpoint", // Now supported with URL encoding
340        ];
341
342        for key in &special_keys {
343            storage.save(key, vec![1, 2, 3]).unwrap();
344        }
345
346        let mut loaded_keys = storage.list_keys().unwrap();
347        loaded_keys.sort();
348        let mut expected = special_keys.clone();
349        expected.sort();
350        assert_eq!(loaded_keys, expected);
351    }
352
353    #[test]
354    fn test_overwrite_existing_key() {
355        let dir = temp_dir();
356        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
357
358        storage.save("test", vec![1, 2, 3]).unwrap();
359        storage.save("test", vec![4, 5, 6]).unwrap();
360
361        let loaded = storage.load("test").unwrap();
362        assert_eq!(loaded, Some(vec![4, 5, 6]));
363    }
364
365    #[test]
366    fn test_extension_in_filename() {
367        let dir = temp_dir();
368        let mut storage = FilePerEvent::new(dir.path(), ".json").unwrap();
369
370        storage.save("test", vec![1, 2, 3]).unwrap();
371
372        // Check that file exists with correct extension
373        let files: Vec<_> = std::fs::read_dir(dir.path())
374            .unwrap()
375            .filter_map(|e| e.ok())
376            .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json"))
377            .collect();
378
379        assert_eq!(files.len(), 1);
380    }
381
382    #[test]
383    fn test_persistence_across_instances() {
384        let dir = temp_dir();
385
386        {
387            let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
388            storage.save("key1", vec![1, 2, 3]).unwrap();
389            storage.save("key2", vec![4, 5, 6]).unwrap();
390        }
391
392        {
393            let storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
394            assert_eq!(storage.load("key1").unwrap(), Some(vec![1, 2, 3]));
395            assert_eq!(storage.load("key2").unwrap(), Some(vec![4, 5, 6]));
396        }
397    }
398
399    // Path Traversal Protection Tests
400
401    #[test]
402    fn test_path_traversal_relative_parent() {
403        let dir = temp_dir();
404        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
405
406        // Attempt to traverse to parent directory - should be rejected by the ".." check
407        let result = storage.save("../../etc/passwd", vec![1, 2, 3]);
408        assert!(result.is_err());
409        assert!(result
410            .unwrap_err()
411            .to_string()
412            .contains("Invalid key: key cannot contain '.' or '..' path segments"));
413    }
414
415    #[test]
416    fn test_path_traversal_absolute_path() {
417        let dir = temp_dir();
418        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
419
420        // Slashes are now allowed and get URL encoded, but this should still work fine
421        let result = storage.save("/absolute/path", vec![1, 2, 3]);
422        assert!(result.is_ok());
423
424        // Verify it can be loaded back
425        let loaded = storage.load("/absolute/path").unwrap();
426        assert_eq!(loaded, Some(vec![1, 2, 3]));
427
428        // The leading "/" is just another character that gets encoded
429        let encoded_filename = "%2Fabsolute%2Fpath.dat".to_string();
430        let file_path = dir.path().join(&encoded_filename);
431        assert!(file_path.exists(), "File should exist with encoded slashes");
432    }
433
434    #[test]
435    fn test_path_traversal_windows_style() {
436        let dir = temp_dir();
437        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
438
439        // Backslashes alone should now work (they get URL encoded)
440        let result = storage.save("windows\\path", vec![1, 2, 3]);
441        assert!(result.is_ok());
442
443        // Verify the data can be loaded back
444        let loaded = storage.load("windows\\path").unwrap();
445        assert_eq!(loaded, Some(vec![1, 2, 3]));
446
447        // Verify the filename contains %5C (encoded backslash)
448        let encoded_filename = "windows%5Cpath.dat".to_string();
449        let file_path = dir.path().join(&encoded_filename);
450        assert!(
451            file_path.exists(),
452            "File should exist with encoded filename"
453        );
454
455        // But ".." should still be rejected
456        let result = storage.save("..\\..\\windows\\system32", vec![1, 2, 3]);
457        assert!(result.is_err());
458        assert!(result
459            .unwrap_err()
460            .to_string()
461            .contains("Invalid key: key cannot contain '.' or '..' path segments"));
462    }
463
464    #[test]
465    fn test_path_traversal_embedded_slash() {
466        let dir = temp_dir();
467        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
468
469        // Key with embedded slash - should now work with URL encoding
470        let result = storage.save("normal/key", vec![1, 2, 3]);
471        assert!(result.is_ok());
472
473        // Verify the data can be loaded back
474        let loaded = storage.load("normal/key").unwrap();
475        assert_eq!(loaded, Some(vec![1, 2, 3]));
476
477        // Verify the filename contains %2F (encoded slash)
478        let encoded_filename = "normal%2Fkey.dat".to_string();
479        let file_path = dir.path().join(&encoded_filename);
480        assert!(
481            file_path.exists(),
482            "File should exist with encoded filename"
483        );
484    }
485
486    #[test]
487    fn test_path_traversal_parent_reference() {
488        let dir = temp_dir();
489        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
490
491        // Just parent reference
492        let result = storage.save("..", vec![1, 2, 3]);
493        assert!(result.is_err());
494        assert!(result
495            .unwrap_err()
496            .to_string()
497            .contains("Invalid key: key cannot contain '.' or '..' path segments"));
498    }
499
500    #[test]
501    fn test_path_traversal_current_directory() {
502        let dir = temp_dir();
503        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
504
505        // Current directory reference
506        let result = storage.save(".", vec![1, 2, 3]);
507        assert!(result.is_err());
508        assert!(result
509            .unwrap_err()
510            .to_string()
511            .contains("Invalid key: key cannot contain '.' or '..' path segments"));
512    }
513
514    #[test]
515    fn test_path_traversal_url_encoded_slash() {
516        let dir = temp_dir();
517        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
518
519        // Keys that already contain percent-encoded characters should work
520        // This tests that we can use keys like "test%2Ffile" literally
521        let result = storage.save("test%2Ffile", vec![1, 2, 3]);
522        assert!(result.is_ok());
523
524        // Verify the data can be loaded back
525        let loaded = storage.load("test%2Ffile").unwrap();
526        assert_eq!(loaded, Some(vec![1, 2, 3]));
527
528        // The % itself will be encoded, so we get %252F (% becomes %25)
529        let encoded_filename = "test%252Ffile.dat".to_string();
530        let file_path = dir.path().join(&encoded_filename);
531        assert!(
532            file_path.exists(),
533            "File should exist with double-encoded filename"
534        );
535    }
536
537    #[test]
538    fn test_path_traversal_url_encoded_backslash() {
539        let dir = temp_dir();
540        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
541
542        // Keys that already contain percent-encoded characters should work
543        // This tests that we can use keys like "test%5Cfile" literally
544        let result = storage.save("test%5Cfile", vec![1, 2, 3]);
545        assert!(result.is_ok());
546
547        // Verify the data can be loaded back
548        let loaded = storage.load("test%5Cfile").unwrap();
549        assert_eq!(loaded, Some(vec![1, 2, 3]));
550
551        // The % itself will be encoded, so we get %255C (% becomes %25)
552        let encoded_filename = "test%255Cfile.dat".to_string();
553        let file_path = dir.path().join(&encoded_filename);
554        assert!(
555            file_path.exists(),
556            "File should exist with double-encoded filename"
557        );
558    }
559
560    #[test]
561    fn test_legitimate_key_simple() {
562        let dir = temp_dir();
563        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
564
565        // Simple legitimate key
566        let result = storage.save("event1", vec![1, 2, 3]);
567        assert!(result.is_ok());
568
569        let loaded = storage.load("event1").unwrap();
570        assert_eq!(loaded, Some(vec![1, 2, 3]));
571    }
572
573    #[test]
574    fn test_legitimate_key_with_colon() {
575        let dir = temp_dir();
576        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
577
578        // Key with colon (common in namespaced keys)
579        let result = storage.save("user:login", vec![1, 2, 3]);
580        assert!(result.is_ok());
581
582        let loaded = storage.load("user:login").unwrap();
583        assert_eq!(loaded, Some(vec![1, 2, 3]));
584    }
585
586    #[test]
587    fn test_legitimate_key_with_spaces() {
588        let dir = temp_dir();
589        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
590
591        // Key with spaces
592        let result = storage.save("user login event", vec![1, 2, 3]);
593        assert!(result.is_ok());
594
595        let loaded = storage.load("user login event").unwrap();
596        assert_eq!(loaded, Some(vec![1, 2, 3]));
597    }
598
599    #[test]
600    fn test_legitimate_key_with_special_chars() {
601        let dir = temp_dir();
602        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
603
604        // Key with various special characters
605        let result = storage.save("event!@#$%^&*()", vec![1, 2, 3]);
606        assert!(result.is_ok());
607
608        let loaded = storage.load("event!@#$%^&*()").unwrap();
609        assert_eq!(loaded, Some(vec![1, 2, 3]));
610    }
611
612    #[test]
613    fn test_load_with_invalid_key() {
614        let dir = temp_dir();
615        let storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
616
617        // Attempt to load with ".." should fail
618        let result = storage.load("../../etc/passwd");
619        assert!(result.is_err());
620        assert!(result
621            .unwrap_err()
622            .to_string()
623            .contains("Invalid key: key cannot contain '.' or '..' path segments"));
624    }
625
626    #[test]
627    fn test_delete_with_invalid_key() {
628        let dir = temp_dir();
629        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
630
631        // Attempt to delete with ".." should fail
632        let result = storage.delete("../../etc/passwd");
633        assert!(result.is_err());
634        assert!(result
635            .unwrap_err()
636            .to_string()
637            .contains("Invalid key: key cannot contain '.' or '..' path segments"));
638    }
639
640    #[test]
641    fn test_key_with_forward_slash() {
642        let dir = temp_dir();
643        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
644
645        // Test that keys with forward slashes work correctly
646        let key = "api/endpoint";
647        let data = vec![1, 2, 3, 4, 5];
648
649        // Save should succeed
650        storage.save(key, data.clone()).unwrap();
651
652        // Load should retrieve the same data
653        let loaded = storage.load(key).unwrap();
654        assert_eq!(loaded, Some(data.clone()));
655
656        // Verify the filename on disk contains %2F (encoded slash)
657        let encoded_filename = "api%2Fendpoint.dat".to_string();
658        let file_path = dir.path().join(&encoded_filename);
659        assert!(
660            file_path.exists(),
661            "File should exist with %2F for forward slash"
662        );
663
664        // Verify it appears in list_keys
665        let keys = storage.list_keys().unwrap();
666        assert!(keys.contains(&key.to_string()));
667
668        // Delete should work
669        storage.delete(key).unwrap();
670        assert_eq!(storage.load(key).unwrap(), None);
671    }
672
673    #[test]
674    fn test_key_with_backslash() {
675        let dir = temp_dir();
676        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
677
678        // Test that keys with backslashes work correctly
679        let key = "path\\to\\resource";
680        let data = vec![6, 7, 8, 9, 10];
681
682        // Save should succeed
683        storage.save(key, data.clone()).unwrap();
684
685        // Load should retrieve the same data
686        let loaded = storage.load(key).unwrap();
687        assert_eq!(loaded, Some(data.clone()));
688
689        // Verify the filename on disk contains %5C (encoded backslash)
690        let encoded_filename = "path%5Cto%5Cresource.dat".to_string();
691        let file_path = dir.path().join(&encoded_filename);
692        assert!(
693            file_path.exists(),
694            "File should exist with %5C for backslash"
695        );
696
697        // Verify it appears in list_keys
698        let keys = storage.list_keys().unwrap();
699        assert!(keys.contains(&key.to_string()));
700
701        // Delete should work
702        storage.delete(key).unwrap();
703        assert_eq!(storage.load(key).unwrap(), None);
704    }
705
706    #[test]
707    fn test_atomic_write_no_temp_files_left() {
708        let dir = temp_dir();
709        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
710
711        // Save multiple files
712        storage.save("key1", vec![1, 2, 3]).unwrap();
713        storage.save("key2", vec![4, 5, 6]).unwrap();
714        storage.save("key3", vec![7, 8, 9]).unwrap();
715
716        // Verify no temporary files remain
717        let entries = std::fs::read_dir(dir.path()).unwrap();
718        for entry in entries {
719            let entry = entry.unwrap();
720            let path = entry.path();
721            if let Some(ext) = path.extension() {
722                let ext_str = ext.to_string_lossy();
723                assert!(
724                    !ext_str.starts_with("tmp."),
725                    "Found leftover temp file: {:?}",
726                    path
727                );
728            }
729        }
730
731        // Verify data is correct
732        assert_eq!(storage.load("key1").unwrap(), Some(vec![1, 2, 3]));
733        assert_eq!(storage.load("key2").unwrap(), Some(vec![4, 5, 6]));
734        assert_eq!(storage.load("key3").unwrap(), Some(vec![7, 8, 9]));
735    }
736
737    #[test]
738    fn test_atomic_write_overwrites_existing() {
739        let dir = temp_dir();
740        let mut storage = FilePerEvent::new(dir.path(), ".dat").unwrap();
741
742        // Save initial data
743        storage.save("test", vec![1, 2, 3]).unwrap();
744        assert_eq!(storage.load("test").unwrap(), Some(vec![1, 2, 3]));
745
746        // Overwrite with new data
747        storage.save("test", vec![4, 5, 6, 7, 8]).unwrap();
748        assert_eq!(storage.load("test").unwrap(), Some(vec![4, 5, 6, 7, 8]));
749
750        // Verify no temp files remain
751        let entries = std::fs::read_dir(dir.path()).unwrap();
752        let temp_files: Vec<_> = entries
753            .filter_map(|e| e.ok())
754            .filter(|e| {
755                e.path()
756                    .extension()
757                    .and_then(|ext| ext.to_str())
758                    .map(|s| s.starts_with("tmp."))
759                    .unwrap_or(false)
760            })
761            .collect();
762
763        assert_eq!(temp_files.len(), 0, "Found {} temp files", temp_files.len());
764    }
765}