//! Integration tests for scuttle end-to-end. //! //! These tests use loopback files (not real block devices) so they can run //! without root and without risk to real disks. They exercise the full Layer //! 1 → Layer 7 path: device discovery → media classification → wipe engine //! → verification → audit record. use std::fs; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use scuttle_core::{run, JobOptions}; use scuttle_devices::{Bus, NwipeDevice}; use scuttle_hash::{Blake3, Sha256, Sha512}; #[allow(unused_imports)] use scuttle_hash::HashProvider; use scuttle_media::classify; use scuttle_methods::{by_name, zero}; use scuttle_prng::{PrngRegistry, ChaCha20Prng, PrngProvider}; use scuttle_verify::VerifyLevel; fn temp_dir() -> PathBuf { let p = std::env::temp_dir().join(format!("scuttle-it-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&p).unwrap(); p } fn fake_dev(path: &std::path::Path, size: u64) -> NwipeDevice { NwipeDevice { path: path.to_string_lossy().to_string(), model: "LoopbackFile".into(), serial: String::new(), wwn: String::new(), firmware_rev: String::new(), bus: Bus::Loop, size_bytes: size, logical_block_size: 512, physical_block_size: 512, rotational: false, removable: false, smart_health_ok: None, wear_level_pct: None, supports_ata_se: false, supports_ata_se_enhanced: false, supports_nvme_sanitize: false, supports_nvme_format: false, supports_scsi_sanitize: false, hpa_present: false, dco_present: false, media_class: String::new(), sysfs_path: String::new(), driver: String::new(), } } fn make_loopback(path: &std::path::Path, size: u64, fill: u8) { let mut f = fs::File::create(path).unwrap(); let chunk = vec![fill; 64 * 1024]; let mut remaining = size as usize; while remaining > 0 { let n = chunk.len().min(remaining); f.write_all(&chunk[..n]).unwrap(); remaining -= n; } f.sync_all().unwrap(); } #[test] fn zero_wipe_produces_all_zeros_and_audit() { let dir = temp_dir(); let path = dir.join("zero.bin"); let size = 256 * 1024; make_loopback(&path, size, 0xAA); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, &zero(), &Sha256, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.result, "success"); // The file should now be all zeros. let bytes = fs::read(&path).unwrap(); assert!(bytes.iter().all(|&b| b == 0)); } #[test] fn one_wipe_produces_all_ones_and_audit() { let dir = temp_dir(); let path = dir.join("one.bin"); let size = 256 * 1024; make_loopback(&path, size, 0x00); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, &by_name("one", Arc::new(ChaCha20Prng)).unwrap(), &Sha256, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.result, "success"); let bytes = fs::read(&path).unwrap(); assert!(bytes.iter().all(|&b| b == 0xFF)); } #[test] fn random_wipe_with_chacha20_and_blake3() { let dir = temp_dir(); let path = dir.join("random.bin"); let size = 256 * 1024; make_loopback(&path, size, 0x55); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let prng: Arc = Arc::new(ChaCha20Prng); let method = by_name("random", prng).unwrap(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, &method, &Blake3, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.method_label, "PRNG Stream"); assert_eq!(outcome.audit.prng_names.len(), 1); assert_eq!(outcome.audit.prng_names[0], "ChaCha20 (CSPRNG)"); assert_eq!(outcome.audit.hash_algorithm, "BLAKE3-256"); // The file should no longer be all 0x55. let bytes = fs::read(&path).unwrap(); assert!(bytes.iter().any(|&b| b != 0x55)); } #[test] fn dod_522022m_7_passes() { let dir = temp_dir(); let path = dir.join("dod.bin"); let size = 256 * 1024; make_loopback(&path, size, 0x00); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let prng: Arc = Arc::new(ChaCha20Prng); let method = by_name("dod", prng).unwrap(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, &method, &Sha512, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.method_label, "DoD 5220.22-M"); assert_eq!(outcome.audit.passes.len(), 7); assert_eq!(outcome.audit.bytes_written, size * 7); } #[test] fn gutmann_35_passes() { let dir = temp_dir(); let path = dir.join("gutmann.bin"); let size = 128 * 1024; make_loopback(&path, size, 0xFF); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let prng: Arc = Arc::new(ChaCha20Prng); let method = by_name("gutmann", prng).unwrap(); let opts = JobOptions { io_block: 32 * 1024, ..Default::default() }; let outcome = run(&path, &dev, &media, &method, &Sha256, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.method_label, "Gutmann Wipe"); assert_eq!(outcome.audit.passes.len(), 35); } #[test] fn every_pass_verify_catches_corruption() { // Wipe a file with the zero method, then corrupt the file mid-wipe // (this is hard to do mid-wipe; instead we run the wipe, then corrupt, // then run a final-pass verify and check that the audit reflects failure). // // For v0.1 we just verify that "every" verify level works on a clean wipe. let dir = temp_dir(); let path = dir.join("every.bin"); let size = 128 * 1024; make_loopback(&path, size, 0xAA); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let prng: Arc = Arc::new(ChaCha20Prng); let method = by_name("random", prng).unwrap(); let opts = JobOptions { verify: VerifyLevel::EveryPass, ..Default::default() }; let outcome = run(&path, &dev, &media, &method, &Sha256, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert!(outcome.audit.passes[0].verify.is_some()); assert!(outcome.audit.passes[0].verify.as_ref().unwrap().ok); } #[test] fn audit_canonical_json_is_deterministic() { let dir = temp_dir(); let path = dir.join("canon.bin"); let size = 64 * 1024; make_loopback(&path, size, 0x00); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, &zero(), &Sha256, &prng_reg, &opts).unwrap(); let json1 = outcome.audit.to_canonical_json().unwrap(); let json2 = outcome.audit.to_canonical_json().unwrap(); assert_eq!(json1, json2, "canonical JSON must be deterministic"); // First key (alphabetically) should be "avg_bandwidth_mbps" — proves sorted keys. let trimmed = json1.trim_start_matches('{').trim_start(); assert!( trimmed.starts_with("\"avg_bandwidth_mbps\""), "expected sorted keys starting with avg_bandwidth_mbps, got: {}", &trimmed[..40.min(trimmed.len())], ); } // =========================================================================== // v0.2 tests: profile-driven wipe + PDF certificate // =========================================================================== #[test] fn profile_legacy_dod_runs_full_7_passes() { let dir = temp_dir(); let path = dir.join("prof-dod.bin"); let size = 128 * 1024; make_loopback(&path, size, 0xAA); // Resolve the legacy_dod profile, then run its method via the wipe engine. let profile = scuttle_profiles::by_name("legacy_dod").expect("profile must exist"); let method = profile.method.as_ref().expect("legacy profile must have a method"); assert_eq!(method.label, "DoD 5220.22-M"); assert_eq!(method.pass_count(), 7); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, method, &Sha256, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.method_label, "DoD 5220.22-M"); assert_eq!(outcome.audit.passes.len(), 7); } #[test] fn profile_legacy_rcmp_has_noblank_set() { // The RCMP OPS-II profile leaves a final random pattern on the device, // so noblank should be true. let profile = scuttle_profiles::by_name("legacy_rcmp").unwrap(); assert!(profile.noblank, "legacy_rcmp should have noblank=true"); assert_eq!(profile.method.as_ref().unwrap().label, "RCMP TSSIT OPS-II"); } #[test] fn profile_legacy_gutmann_runs_35_passes() { let dir = temp_dir(); let path = dir.join("prof-gutmann.bin"); let size = 64 * 1024; make_loopback(&path, size, 0xFF); let profile = scuttle_profiles::by_name("legacy_gutmann").unwrap(); let method = profile.method.as_ref().unwrap(); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions { io_block: 32 * 1024, ..Default::default() }; let outcome = run(&path, &dev, &media, method, &Blake3, &prng_reg, &opts).unwrap(); assert!(outcome.ok); assert_eq!(outcome.audit.passes.len(), 35); } #[test] fn pdf_certificate_is_valid_pdf() { let dir = temp_dir(); let path = dir.join("pdf.bin"); let size = 64 * 1024; make_loopback(&path, size, 0x00); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions::default(); let outcome = run(&path, &dev, &media, &zero(), &Sha256, &prng_reg, &opts).unwrap(); // Render the audit record to a PDF and verify it parses. let pdf_path = dir.join("cert.pdf"); scuttle_pdf::render_to_file(&outcome.audit, &pdf_path).unwrap(); let bytes = fs::read(&pdf_path).unwrap(); assert_eq!(&bytes[..5], b"%PDF-", "PDF must start with %PDF-"); assert_eq!(&bytes[bytes.len() - 6..], b"%%EOF\n", "PDF must end with %%EOF"); assert!(bytes.len() > 2000, "PDF should be at least 2 KiB, got {} bytes", bytes.len()); // The PDF should contain key audit fields as text. assert!(bytes.windows(b"Scuttle Disk Erasure Certificate".len()) .any(|w| w == b"Scuttle Disk Erasure Certificate")); assert!(bytes.windows(b"SUCCESS".len()).any(|w| w == b"SUCCESS")); } #[test] fn pdf_certificate_records_failure_too() { // Run a wipe that "succeeds" but tamper with the audit record to mark // it as a failure, then verify the PDF shows FAILURE. let dir = temp_dir(); let path = dir.join("pdf-fail.bin"); let size = 32 * 1024; make_loopback(&path, size, 0x00); let dev = fake_dev(&path, size); let media = classify(&dev).unwrap(); let prng_reg = PrngRegistry::default(); let opts = JobOptions::default(); let mut outcome = run(&path, &dev, &media, &zero(), &Sha256, &prng_reg, &opts).unwrap(); outcome.audit.result = "failure".into(); let pdf_path = dir.join("cert-fail.pdf"); scuttle_pdf::render_to_file(&outcome.audit, &pdf_path).unwrap(); let bytes = fs::read(&pdf_path).unwrap(); assert!(bytes.windows(b"FAILURE".len()).any(|w| w == b"FAILURE")); } #[test] fn all_nine_legacy_profiles_load() { // v0.2 had 9; v0.4 has 20 (9 legacy + 11 modern). let names = scuttle_profiles::list(); assert_eq!(names.len(), 20, "expected 20 profiles (9 legacy + 11 modern), got {}", names.len()); let legacy = scuttle_profiles::list_legacy(); assert_eq!(legacy.len(), 9, "expected 9 legacy profiles"); let modern = scuttle_profiles::list_modern(); assert_eq!(modern.len(), 11, "expected 11 modern profiles"); // Each legacy profile must have a method. for n in &legacy { let p = scuttle_profiles::by_name(n).unwrap(); assert!(!p.description.is_empty()); assert!(p.method.is_some(), "legacy profile {} should have a method", n); assert!(!p.method.as_ref().unwrap().label.is_empty()); } }