332 lines
12 KiB
Rust
Executable File
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());
|
|
}
|
|
}
|
|
|