Skip to main content

shadowforge_lib/adapters/
opsec.rs

1//! Operational security adapters implementing emergency wipe and amnesiac operations.
2
3use crate::domain::errors::OpsecError;
4use crate::domain::ports::{
5    AmnesiaPipeline, EmbedTechnique, ForensicWatermarker, GeographicDistributor, PanicWiper,
6};
7use crate::domain::types::{
8    CoverMedia, GeographicManifest, PanicWipeConfig, Payload, WatermarkReceipt,
9    WatermarkTripwireTag,
10};
11use rand::Rng;
12use std::fs::{self, OpenOptions};
13use std::io::{Read, Seek, SeekFrom, Write};
14use std::path::Path;
15
16/// Secure panic wiper using a 3-pass overwrite-then-delete strategy.
17///
18/// Overwrites files with:
19/// 1. Pass 1: All zeros
20/// 2. Pass 2: All ones (0xFF)
21/// 3. Pass 3: Cryptographically random data
22///
23/// Then truncates and deletes the file. Continues on errors to ensure
24/// maximum coverage even under duress.
25pub struct SecurePanicWiper;
26
27impl SecurePanicWiper {
28    /// Create a new secure panic wiper.
29    #[must_use]
30    pub const fn new() -> Self {
31        Self
32    }
33
34    /// Securely wipe a single file using 3-pass overwrite.
35    fn wipe_file(path: &Path) -> Result<(), String> {
36        // Check if file exists
37        if !path.exists() {
38            return Err(format!("file does not exist: {}", path.display()));
39        }
40
41        // Get file size
42        let metadata = fs::metadata(path).map_err(|e| format!("failed to get metadata: {e}"))?;
43        let file_size = metadata.len();
44
45        if file_size == 0 {
46            // Empty file, just delete it
47            fs::remove_file(path).map_err(|e| format!("failed to delete empty file: {e}"))?;
48            return Ok(());
49        }
50
51        // Open file for reading and writing
52        let mut file = OpenOptions::new()
53            .write(true)
54            .open(path)
55            .map_err(|e| format!("failed to open file for wiping: {e}"))?;
56
57        // Pass 1: Overwrite with zeros
58        file.seek(SeekFrom::Start(0))
59            .map_err(|e| format!("failed to seek (pass 1): {e}"))?;
60        let zeros = vec![0u8; 4096];
61        let mut remaining = file_size;
62        while remaining > 0 {
63            let chunk_size = remaining.min(4096) as usize;
64            file.write_all(
65                zeros
66                    .get(..chunk_size)
67                    .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
68            )
69            .map_err(|e| format!("failed to write zeros: {e}"))?;
70            remaining -= chunk_size as u64;
71        }
72        file.flush()
73            .map_err(|e| format!("failed to flush (pass 1): {e}"))?;
74
75        // Pass 2: Overwrite with 0xFF
76        file.seek(SeekFrom::Start(0))
77            .map_err(|e| format!("failed to seek (pass 2): {e}"))?;
78        let ones = vec![0xFFu8; 4096];
79        let mut remaining = file_size;
80        while remaining > 0 {
81            let chunk_size = remaining.min(4096) as usize;
82            file.write_all(
83                ones.get(..chunk_size)
84                    .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
85            )
86            .map_err(|e| format!("failed to write ones: {e}"))?;
87            remaining -= chunk_size as u64;
88        }
89        file.flush()
90            .map_err(|e| format!("failed to flush (pass 2): {e}"))?;
91
92        // Pass 3: Overwrite with random data
93        file.seek(SeekFrom::Start(0))
94            .map_err(|e| format!("failed to seek (pass 3): {e}"))?;
95        let mut rng = rand::rng();
96        let mut random_buf = vec![0u8; 4096];
97        let mut remaining = file_size;
98        while remaining > 0 {
99            let chunk_size = remaining.min(4096) as usize;
100            rng.fill_bytes(
101                random_buf
102                    .get_mut(..chunk_size)
103                    .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
104            );
105            file.write_all(
106                random_buf
107                    .get(..chunk_size)
108                    .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
109            )
110            .map_err(|e| format!("failed to write random data: {e}"))?;
111            remaining -= chunk_size as u64;
112        }
113        file.flush()
114            .map_err(|e| format!("failed to flush (pass 3): {e}"))?;
115
116        // Truncate to zero length
117        file.set_len(0)
118            .map_err(|e| format!("failed to truncate: {e}"))?;
119
120        // Close the file
121        drop(file);
122
123        // Delete the file
124        fs::remove_file(path).map_err(|e| format!("failed to delete file: {e}"))?;
125
126        Ok(())
127    }
128
129    /// Recursively wipe all files in a directory, then remove the directory.
130    fn wipe_dir_recursive(path: &Path) -> Result<(), String> {
131        if !path.exists() {
132            return Err(format!("directory does not exist: {}", path.display()));
133        }
134
135        if !path.is_dir() {
136            return Err(format!("path is not a directory: {}", path.display()));
137        }
138
139        // Read directory entries
140        let entries = fs::read_dir(path)
141            .map_err(|e| format!("failed to read directory: {e}"))?
142            .collect::<Result<Vec<_>, _>>()
143            .map_err(|e| format!("failed to collect entries: {e}"))?;
144
145        // Wipe each entry
146        for entry in entries {
147            let entry_path = entry.path();
148
149            if entry_path.is_dir() {
150                // Recursively wipe subdirectory (errors silently ignored
151                // to avoid leaking paths to stderr during wipe)
152                let _ = Self::wipe_dir_recursive(&entry_path);
153            } else {
154                let _ = Self::wipe_file(&entry_path);
155            }
156        }
157
158        // Remove the now-empty directory
159        fs::remove_dir(path).map_err(|e| format!("failed to remove directory: {e}"))?;
160
161        Ok(())
162    }
163}
164
165impl Default for SecurePanicWiper {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl PanicWiper for SecurePanicWiper {
172    fn wipe(&self, config: &PanicWipeConfig) -> Result<(), OpsecError> {
173        let mut failures = Vec::new();
174
175        // Wipe all key files — never print paths to stderr (opsec).
176        for path in &config.key_paths {
177            if let Err(e) = Self::wipe_file(path) {
178                failures.push((path.display().to_string(), e));
179            }
180        }
181
182        // Wipe all config files
183        for path in &config.config_paths {
184            if let Err(e) = Self::wipe_file(path) {
185                failures.push((path.display().to_string(), e));
186            }
187        }
188
189        // Wipe all temp directories
190        for path in &config.temp_dirs {
191            if let Err(e) = Self::wipe_dir_recursive(path) {
192                failures.push((path.display().to_string(), e));
193            }
194        }
195
196        // If there were any failures, return the first one as an error
197        // (but all wipe operations were attempted)
198        if let Some((path, reason)) = failures.first() {
199            return Err(OpsecError::WipeStepFailed {
200                path: path.clone(),
201                reason: reason.clone(),
202            });
203        }
204
205        Ok(())
206    }
207}
208
209/// In-memory amnesiac pipeline adapter.
210///
211/// Delegates to [`crate::domain::opsec::embed_in_memory`] to ensure
212/// zero filesystem writes during the embed/extract cycle.
213pub struct AmnesiaPipelineImpl;
214
215impl AmnesiaPipelineImpl {
216    /// Create a new amnesiac pipeline adapter.
217    #[must_use]
218    pub const fn new() -> Self {
219        Self
220    }
221}
222
223impl Default for AmnesiaPipelineImpl {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl AmnesiaPipeline for AmnesiaPipelineImpl {
230    fn embed_in_memory(
231        &self,
232        payload_input: &mut dyn Read,
233        cover_input: &mut dyn Read,
234        output: &mut dyn Write,
235        technique: &dyn EmbedTechnique,
236    ) -> Result<(), OpsecError> {
237        crate::domain::opsec::embed_in_memory(payload_input, cover_input, output, technique)
238    }
239}
240
241/// Geographic threshold distribution adapter.
242///
243/// Validates the manifest, then embeds payload shards into covers
244/// annotated with jurisdictional metadata.
245pub struct GeographicDistributorImpl;
246
247impl GeographicDistributorImpl {
248    /// Create a new geographic distributor adapter.
249    #[must_use]
250    pub const fn new() -> Self {
251        Self
252    }
253}
254
255impl Default for GeographicDistributorImpl {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl GeographicDistributor for GeographicDistributorImpl {
262    fn distribute_with_manifest(
263        &self,
264        payload: &Payload,
265        covers: Vec<CoverMedia>,
266        manifest: &GeographicManifest,
267        embedder: &dyn EmbedTechnique,
268    ) -> Result<Vec<CoverMedia>, OpsecError> {
269        // Validate manifest first
270        crate::domain::opsec::validate_manifest(manifest)?;
271
272        if covers.len() < manifest.shards.len() {
273            return Err(OpsecError::ManifestError {
274                reason: format!(
275                    "not enough covers ({}) for manifest entries ({})",
276                    covers.len(),
277                    manifest.shards.len()
278                ),
279            });
280        }
281
282        // For each shard entry, embed payload into the corresponding cover
283        let payload_bytes = payload.as_bytes();
284        let shard_count = manifest.shards.len();
285
286        // Simple equal-size distribution: split payload into N chunks
287        let chunk_size = payload_bytes.len().div_ceil(shard_count);
288        let mut results = Vec::with_capacity(shard_count);
289
290        for (i, cover) in covers.into_iter().enumerate().take(shard_count) {
291            let start = i * chunk_size;
292            let end = (start + chunk_size).min(payload_bytes.len());
293            let chunk = payload_bytes.get(start..end).unwrap_or_default();
294
295            let shard_payload = Payload::from_bytes(chunk.to_vec());
296            let stego =
297                embedder
298                    .embed(cover, &shard_payload)
299                    .map_err(|e| OpsecError::ManifestError {
300                        reason: format!("embed failed for shard {i}: {e}"),
301                    })?;
302            results.push(stego);
303        }
304
305        Ok(results)
306    }
307}
308
309/// Forensic watermark tripwire adapter.
310///
311/// Delegates to [`crate::domain::opsec::embed_watermark`] and
312/// [`crate::domain::opsec::identify_watermark`].
313pub struct ForensicWatermarkerImpl;
314
315impl ForensicWatermarkerImpl {
316    /// Create a new forensic watermarker adapter.
317    #[must_use]
318    pub const fn new() -> Self {
319        Self
320    }
321}
322
323impl Default for ForensicWatermarkerImpl {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl ForensicWatermarker for ForensicWatermarkerImpl {
330    fn embed_tripwire(
331        &self,
332        mut cover: CoverMedia,
333        tag: &WatermarkTripwireTag,
334    ) -> Result<CoverMedia, OpsecError> {
335        crate::domain::opsec::embed_watermark(&mut cover, tag)?;
336        Ok(cover)
337    }
338
339    fn identify_recipient(
340        &self,
341        stego: &CoverMedia,
342        tags: &[WatermarkTripwireTag],
343    ) -> Result<Option<WatermarkReceipt>, OpsecError> {
344        let result = crate::domain::opsec::identify_watermark(stego, tags);
345        Ok(result.and_then(|idx| {
346            let tag = tags.get(idx)?;
347            Some(WatermarkReceipt {
348                recipient: tag.recipient_id.to_string(),
349                algorithm: "lsb-tripwire-v1".into(),
350                shards: vec![],
351                created_at: chrono::Utc::now(),
352            })
353        }))
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::fs::File;
361    use std::io::Read;
362    use tempfile::TempDir;
363
364    type TestResult = Result<(), Box<dyn std::error::Error>>;
365
366    #[test]
367    fn test_wipe_single_file() -> TestResult {
368        let temp_dir = TempDir::new()?;
369        let file_path = temp_dir.path().join("test_key.txt");
370
371        // Create a file with some content
372        fs::write(&file_path, b"secret key data")?;
373
374        assert!(file_path.exists());
375
376        // Wipe the file
377        SecurePanicWiper::wipe_file(&file_path)?;
378
379        // File should no longer exist
380        assert!(!file_path.exists());
381        Ok(())
382    }
383
384    #[test]
385    fn test_wipe_empty_file() -> TestResult {
386        let temp_dir = TempDir::new()?;
387        let file_path = temp_dir.path().join("empty.txt");
388
389        // Create an empty file
390        File::create(&file_path)?;
391        assert!(file_path.exists());
392
393        // Wipe should succeed
394        SecurePanicWiper::wipe_file(&file_path)?;
395        assert!(!file_path.exists());
396        Ok(())
397    }
398
399    #[test]
400    fn test_wipe_nonexistent_file_returns_error() {
401        let result = SecurePanicWiper::wipe_file(Path::new("/nonexistent/file.txt"));
402        assert!(result.is_err());
403        if let Err(msg) = result {
404            assert!(msg.contains("does not exist"));
405        }
406    }
407
408    #[test]
409    fn test_wipe_config_with_multiple_files() -> TestResult {
410        let temp_dir = TempDir::new()?;
411
412        let key1 = temp_dir.path().join("key1.pem");
413        let key2 = temp_dir.path().join("key2.pem");
414        let config_file = temp_dir.path().join("config.toml");
415
416        // Create test files
417        fs::write(&key1, b"private key 1")?;
418        fs::write(&key2, b"private key 2")?;
419        fs::write(&config_file, b"sensitive config")?;
420
421        assert!(key1.exists());
422        assert!(key2.exists());
423        assert!(config_file.exists());
424
425        // Create wipe config
426        let wipe_config = PanicWipeConfig {
427            key_paths: vec![key1.clone(), key2.clone()],
428            config_paths: vec![config_file.clone()],
429            temp_dirs: vec![],
430        };
431
432        let wiper = SecurePanicWiper::new();
433        wiper.wipe(&wipe_config)?;
434
435        // All files should be gone
436        assert!(!key1.exists());
437        assert!(!key2.exists());
438        assert!(!config_file.exists());
439        Ok(())
440    }
441
442    #[test]
443    fn test_wipe_directory_recursive() -> TestResult {
444        let temp_dir = TempDir::new()?;
445        let wipe_dir = temp_dir.path().join("to_wipe");
446        let subdir = wipe_dir.join("subdir");
447
448        // Create directory structure
449        fs::create_dir_all(&subdir)?;
450
451        let file1 = wipe_dir.join("file1.txt");
452        let file2 = subdir.join("file2.txt");
453
454        fs::write(&file1, b"temp data 1")?;
455        fs::write(&file2, b"temp data 2")?;
456
457        assert!(wipe_dir.exists());
458        assert!(file1.exists());
459        assert!(file2.exists());
460
461        // Wipe the directory
462        SecurePanicWiper::wipe_dir_recursive(&wipe_dir)?;
463
464        // Directory and all contents should be gone
465        assert!(!wipe_dir.exists());
466        assert!(!file1.exists());
467        assert!(!file2.exists());
468        Ok(())
469    }
470
471    #[test]
472    fn test_wipe_continues_on_partial_failure() -> TestResult {
473        let temp_dir = TempDir::new()?;
474
475        let key1 = temp_dir.path().join("key1.pem");
476        let nonexistent = temp_dir.path().join("nonexistent.pem");
477        let key2 = temp_dir.path().join("key2.pem");
478
479        // Create only key1 and key2
480        fs::write(&key1, b"key 1")?;
481        fs::write(&key2, b"key 2")?;
482
483        let wipe_config = PanicWipeConfig {
484            key_paths: vec![key1.clone(), nonexistent, key2.clone()],
485            config_paths: vec![],
486            temp_dirs: vec![],
487        };
488
489        let wiper = SecurePanicWiper::new();
490        let result = wiper.wipe(&wipe_config);
491
492        // Should return an error for the nonexistent file
493        assert!(
494            matches!(&result, Err(OpsecError::WipeStepFailed { path, .. }) if path.contains("nonexistent.pem")),
495        );
496
497        // But key1 and key2 should still be wiped
498        assert!(!key1.exists());
499        assert!(!key2.exists());
500        Ok(())
501    }
502
503    #[test]
504    fn test_overwrite_actually_changes_file_content() -> TestResult {
505        let temp_dir = TempDir::new()?;
506        let file_path = temp_dir.path().join("test.dat");
507
508        // Write some recognizable pattern
509        let original_data = b"AAAABBBBCCCCDDDD";
510        fs::write(&file_path, original_data)?;
511
512        // Read back to verify
513        let mut file = File::open(&file_path)?;
514        let mut read_back = Vec::new();
515        file.read_to_end(&mut read_back)?;
516        assert_eq!(read_back, original_data);
517        drop(file);
518
519        // Now open and check size, then wipe
520        let file_size = fs::metadata(&file_path)?.len();
521        assert_eq!(file_size, original_data.len() as u64);
522
523        SecurePanicWiper::wipe_file(&file_path)?;
524
525        // File should be gone
526        assert!(!file_path.exists());
527        Ok(())
528    }
529
530    // ─── AmnesiaPipeline Tests ────────────────────────────────────────────
531
532    use crate::domain::errors::StegoError;
533    use crate::domain::types::{Capacity, CoverMedia, Payload, StegoTechnique};
534    use bytes::Bytes;
535    use std::io::Cursor;
536
537    struct MockEmbedder;
538
539    impl crate::domain::ports::EmbedTechnique for MockEmbedder {
540        fn technique(&self) -> StegoTechnique {
541            StegoTechnique::LsbImage
542        }
543
544        fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
545            Ok(Capacity {
546                bytes: cover.data.len() as u64,
547                technique: StegoTechnique::LsbImage,
548            })
549        }
550
551        fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
552            let mut combined = cover.data.to_vec();
553            combined.extend_from_slice(payload.as_bytes());
554            Ok(CoverMedia {
555                kind: cover.kind,
556                data: Bytes::from(combined),
557                metadata: cover.metadata,
558            })
559        }
560    }
561
562    #[test]
563    fn amnesiac_adapter_embed_roundtrip() -> TestResult {
564        let pipeline = AmnesiaPipelineImpl::new();
565        let mut cover = Cursor::new(b"img-data".to_vec());
566        let mut payload = Cursor::new(b"secret".to_vec());
567        let mut output = Vec::new();
568
569        pipeline.embed_in_memory(&mut payload, &mut cover, &mut output, &MockEmbedder)?;
570
571        assert!(output.starts_with(b"img-data"));
572        assert!(output.ends_with(b"secret"));
573        Ok(())
574    }
575
576    #[test]
577    fn amnesiac_adapter_default() -> TestResult {
578        let pipeline = AmnesiaPipelineImpl;
579        let mut cover = Cursor::new(b"c".to_vec());
580        let mut payload = Cursor::new(b"p".to_vec());
581        let mut output = Vec::new();
582
583        pipeline.embed_in_memory(&mut payload, &mut cover, &mut output, &MockEmbedder)?;
584
585        assert!(!output.is_empty());
586        Ok(())
587    }
588
589    // ─── GeographicDistributor Tests ──────────────────────────────────────
590
591    use crate::domain::types::{CoverMediaKind, GeoShardEntry, GeographicManifest};
592
593    fn test_covers(n: usize) -> Vec<CoverMedia> {
594        (0..n)
595            .map(|i| CoverMedia {
596                kind: CoverMediaKind::PngImage,
597                data: Bytes::from(vec![0u8; 64]),
598                metadata: {
599                    let mut m = std::collections::HashMap::new();
600                    m.insert("idx".into(), i.to_string());
601                    m
602                },
603            })
604            .collect()
605    }
606
607    fn test_geo_manifest() -> GeographicManifest {
608        GeographicManifest {
609            shards: vec![
610                GeoShardEntry {
611                    shard_index: 0,
612                    jurisdiction: "IS".into(),
613                    holder_description: "Iceland contact".into(),
614                },
615                GeoShardEntry {
616                    shard_index: 1,
617                    jurisdiction: "CH".into(),
618                    holder_description: "Swiss contact".into(),
619                },
620                GeoShardEntry {
621                    shard_index: 2,
622                    jurisdiction: "SG".into(),
623                    holder_description: "Singapore contact".into(),
624                },
625            ],
626            minimum_jurisdictions: 2,
627        }
628    }
629
630    #[test]
631    fn geographic_distribute_succeeds() -> TestResult {
632        let distributor = GeographicDistributorImpl::new();
633        let payload = Payload::from_bytes(b"secret payload data here!".to_vec());
634        let covers = test_covers(3);
635        let manifest = test_geo_manifest();
636
637        let results =
638            distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder)?;
639
640        assert_eq!(results.len(), 3);
641        Ok(())
642    }
643
644    #[test]
645    fn geographic_distribute_fails_insufficient_covers() {
646        let distributor = GeographicDistributorImpl::new();
647        let payload = Payload::from_bytes(b"secret".to_vec());
648        let covers = test_covers(1); // Only 1 cover for 3 shards
649        let manifest = test_geo_manifest();
650
651        let result =
652            distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder);
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn geographic_distribute_fails_invalid_manifest() {
658        let distributor = GeographicDistributorImpl::new();
659        let payload = Payload::from_bytes(b"secret".to_vec());
660        let covers = test_covers(1);
661        let manifest = GeographicManifest {
662            shards: vec![GeoShardEntry {
663                shard_index: 0,
664                jurisdiction: "IS".into(),
665                holder_description: "one".into(),
666            }],
667            minimum_jurisdictions: 3, // Needs 3 but only has 1
668        };
669
670        let result =
671            distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder);
672        assert!(result.is_err());
673    }
674
675    #[test]
676    fn geographic_distributor_default() -> TestResult {
677        let distributor = GeographicDistributorImpl;
678        let payload = Payload::from_bytes(b"data".to_vec());
679        let covers = test_covers(3);
680        let manifest = test_geo_manifest();
681
682        let results =
683            distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder)?;
684        assert_eq!(results.len(), 3);
685        Ok(())
686    }
687
688    // ─── ForensicWatermarker Tests ────────────────────────────────────────
689
690    #[test]
691    fn forensic_watermarker_embed_roundtrip() -> TestResult {
692        let wm = ForensicWatermarkerImpl::new();
693        let cover = CoverMedia {
694            kind: CoverMediaKind::PngImage,
695            data: Bytes::from(vec![0u8; 1024]),
696            metadata: std::collections::HashMap::new(),
697        };
698        let tag = WatermarkTripwireTag {
699            recipient_id: uuid::Uuid::new_v4(),
700            embedding_seed: b"adapter-test-seed".to_vec(),
701        };
702
703        let stego = wm.embed_tripwire(cover, &tag)?;
704        let receipt = wm.identify_recipient(&stego, std::slice::from_ref(&tag))?;
705        assert!(receipt.is_some());
706        let receipt = receipt.ok_or("expected watermark receipt")?;
707        assert_eq!(receipt.recipient, tag.recipient_id.to_string());
708        assert_eq!(receipt.algorithm, "lsb-tripwire-v1");
709        Ok(())
710    }
711
712    #[test]
713    fn forensic_watermarker_no_match() -> TestResult {
714        let wm = ForensicWatermarkerImpl;
715        let cover = CoverMedia {
716            kind: CoverMediaKind::PngImage,
717            data: Bytes::from(vec![0u8; 1024]),
718            metadata: std::collections::HashMap::new(),
719        };
720        let unknown_tag = WatermarkTripwireTag {
721            recipient_id: uuid::Uuid::new_v4(),
722            embedding_seed: b"unknown".to_vec(),
723        };
724
725        let result = wm.identify_recipient(&cover, &[unknown_tag])?;
726        assert!(result.is_none());
727        Ok(())
728    }
729
730    #[test]
731    fn wipe_dir_recursive_nonexistent() {
732        let result = SecurePanicWiper::wipe_dir_recursive(Path::new("/nonexistent/dir"));
733        assert!(result.is_err());
734        if let Err(msg) = result {
735            assert!(msg.contains("does not exist"));
736        }
737    }
738
739    #[test]
740    fn wipe_dir_recursive_not_a_directory() -> TestResult {
741        let temp_dir = TempDir::new()?;
742        let file_path = temp_dir.path().join("regular_file.txt");
743        fs::write(&file_path, b"data")?;
744
745        let result = SecurePanicWiper::wipe_dir_recursive(&file_path);
746        assert!(result.is_err());
747        if let Err(msg) = result {
748            assert!(msg.contains("not a directory"));
749        }
750        Ok(())
751    }
752
753    #[test]
754    fn wipe_config_with_temp_dirs() -> TestResult {
755        let temp_dir = TempDir::new()?;
756        let temp_subdir = temp_dir.path().join("cache");
757        fs::create_dir_all(&temp_subdir)?;
758        let cache_file = temp_subdir.join("cached.dat");
759        fs::write(&cache_file, b"cached stuff")?;
760
761        let config_file = temp_dir.path().join("settings.toml");
762        fs::write(&config_file, b"[settings]\nkey = true")?;
763
764        let wipe_config = PanicWipeConfig {
765            key_paths: vec![],
766            config_paths: vec![config_file.clone()],
767            temp_dirs: vec![temp_subdir.clone()],
768        };
769
770        let wiper = SecurePanicWiper::new();
771        wiper.wipe(&wipe_config)?;
772
773        assert!(!config_file.exists());
774        assert!(!temp_subdir.exists());
775        Ok(())
776    }
777
778    #[test]
779    fn wipe_config_with_missing_temp_dir_returns_error() -> TestResult {
780        let temp_dir = TempDir::new()?;
781        let nonexistent_dir = temp_dir.path().join("no_such_dir");
782
783        let wipe_config = PanicWipeConfig {
784            key_paths: vec![],
785            config_paths: vec![],
786            temp_dirs: vec![nonexistent_dir],
787        };
788
789        let wiper = SecurePanicWiper::new();
790        let result = wiper.wipe(&wipe_config);
791        assert!(matches!(result, Err(OpsecError::WipeStepFailed { .. })));
792        Ok(())
793    }
794
795    #[test]
796    fn wipe_large_file() -> TestResult {
797        let temp_dir = TempDir::new()?;
798        let file_path = temp_dir.path().join("large.dat");
799        // Create a file larger than 4096 bytes to exercise multi-chunk loop
800        let data = vec![0x42u8; 8192];
801        fs::write(&file_path, &data)?;
802
803        SecurePanicWiper::wipe_file(&file_path)?;
804        assert!(!file_path.exists());
805        Ok(())
806    }
807
808    #[test]
809    fn geographic_distributor_extra_covers_ignored() -> TestResult {
810        let distributor = GeographicDistributorImpl::new();
811        let payload = Payload::from_bytes(b"data for geographic dist".to_vec());
812        // 5 covers for only 3 manifest shards — extras get dropped
813        let covers = test_covers(5);
814        let manifest = test_geo_manifest();
815
816        let results =
817            distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder)?;
818        // Only 3 manifest shards, so only 3 results
819        assert_eq!(results.len(), 3);
820        Ok(())
821    }
822}