scuttle/crates/scuttle-core/tests/integration.rs

332 lines
12 KiB
Rust
Executable File

//! 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<dyn PrngProvider> = 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<dyn PrngProvider> = 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<dyn PrngProvider> = 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<dyn PrngProvider> = 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());
}
}