#![cfg(feature = "luks")]
use std::path::Path;
use std::process::Command;
use fstool::block::luks::{FormatOpts, LuksBackend, Version, format};
use fstool::block::{BlockDevice, FileBackend};
use tempfile::TempDir;
fn which(tool: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {tool}"))
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
const PASSPHRASE: &str = "correct horse battery staple";
fn cryptsetup_format(path: &Path, extra: &[&str]) -> bool {
let mut cmd = Command::new("cryptsetup");
cmd.arg("luksFormat")
.arg("-q")
.arg("--pbkdf")
.arg("pbkdf2")
.arg("--pbkdf-force-iterations")
.arg("1000")
.args(extra)
.arg(path)
.arg("-");
let out = cmd
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(PASSPHRASE.as_bytes())?;
child.wait_with_output()
})
.expect("spawning cryptsetup");
if !out.status.success() {
eprintln!(
"cryptsetup luksFormat {extra:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
out.status.success()
}
fn cryptsetup_master_key(path: &Path) -> Option<Vec<u8>> {
let out = Command::new("cryptsetup")
.args(["luksDump", "--dump-master-key", "-q"])
.arg(path)
.arg("-")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(PASSPHRASE.as_bytes())?;
child.wait_with_output()
})
.ok()?;
if !out.status.success() {
eprintln!(
"cryptsetup luksDump failed: {}",
String::from_utf8_lossy(&out.stderr)
);
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let after = text.split("MK dump:").nth(1)?;
let mut key = Vec::new();
for tok in after.split_whitespace() {
match u8::from_str_radix(tok, 16) {
Ok(b) if tok.len() == 2 => key.push(b),
_ => break,
}
}
(!key.is_empty()).then_some(key)
}
fn cryptsetup_dump(path: &Path) -> String {
let out = Command::new("cryptsetup")
.arg("luksDump")
.arg(path)
.output()
.expect("spawning cryptsetup");
assert!(
out.status.success(),
"cryptsetup luksDump rejected our header: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn qemu_io(path: &Path, cmds: &[&str]) -> std::process::Output {
let mut cmd = Command::new("qemu-io");
cmd.arg("--object")
.arg(format!("secret,id=sec0,data={PASSPHRASE}"))
.arg("--image-opts")
.arg(format!(
"driver=luks,file.filename={},key-secret=sec0",
path.display()
));
for c in cmds {
cmd.arg("-c").arg(c);
}
cmd.output().expect("spawning qemu-io")
}
fn blank(dir: &TempDir, name: &str, size: u64) -> std::path::PathBuf {
let path = dir.path().join(name);
let f = std::fs::File::create(&path).unwrap();
f.set_len(size).unwrap();
path
}
fn open_cryptsetup_container(name: &str, extra: &[&str]) {
if !which("cryptsetup") {
eprintln!("skipping: cryptsetup not installed");
return;
}
let dir = TempDir::new().unwrap();
let img = blank(&dir, name, 64 * 1024 * 1024);
if !cryptsetup_format(&img, extra) {
eprintln!("skipping {name}: this host's cryptsetup refused {extra:?}");
return;
}
let Some(expect) = cryptsetup_master_key(&img) else {
eprintln!("skipping {name}: cryptsetup would not dump the master key");
return;
};
let vol = LuksBackend::open(FileBackend::open(&img).unwrap(), PASSPHRASE).unwrap();
assert_eq!(
vol.master_key().as_bytes(),
&expect[..],
"{name}: master key mismatch"
);
assert!(vol.total_size() > 0);
assert!(!vol.header().uuid().is_empty());
}
#[test]
fn opens_cryptsetup_luks1() {
open_cryptsetup_container("cs-luks1.img", &["--type", "luks1"]);
}
#[test]
fn opens_cryptsetup_luks2() {
open_cryptsetup_container("cs-luks2.img", &["--type", "luks2"]);
}
#[test]
fn opens_cryptsetup_luks1_cbc_essiv() {
open_cryptsetup_container(
"cs-luks1-cbc.img",
&["--type", "luks1", "-c", "aes-cbc-essiv:sha256", "-s", "256"],
);
}
#[test]
fn opens_cryptsetup_luks2_4k_sectors() {
open_cryptsetup_container(
"cs-luks2-4k.img",
&["--type", "luks2", "--sector-size", "4096"],
);
}
#[test]
fn opens_cryptsetup_luks2_argon2id() {
if !which("cryptsetup") {
eprintln!("skipping: cryptsetup not installed");
return;
}
let dir = TempDir::new().unwrap();
let img = blank(&dir, "cs-argon2id.img", 64 * 1024 * 1024);
let ok = Command::new("cryptsetup")
.args([
"luksFormat",
"-q",
"--type",
"luks2",
"--pbkdf",
"argon2id",
"--pbkdf-force-iterations",
"4",
"--pbkdf-memory",
"32",
"--pbkdf-parallel",
"1",
])
.arg(&img)
.arg("-")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child
.stdin
.as_mut()
.unwrap()
.write_all(PASSPHRASE.as_bytes())?;
child.wait_with_output()
})
.expect("spawning cryptsetup");
if !ok.status.success() {
eprintln!(
"skipping: cryptsetup refused argon2id: {}",
String::from_utf8_lossy(&ok.stderr)
);
return;
}
let Some(expect) = cryptsetup_master_key(&img) else {
eprintln!("skipping: cryptsetup would not dump the master key");
return;
};
let vol = LuksBackend::open(FileBackend::open(&img).unwrap(), PASSPHRASE).unwrap();
assert_eq!(vol.master_key().as_bytes(), &expect[..]);
}
#[test]
fn refuses_a_wrong_passphrase_on_a_real_container() {
if !which("cryptsetup") {
eprintln!("skipping: cryptsetup not installed");
return;
}
let dir = TempDir::new().unwrap();
let img = blank(&dir, "cs-wrongpw.img", 64 * 1024 * 1024);
if !cryptsetup_format(&img, &["--type", "luks2"]) {
return;
}
let err =
LuksBackend::open(FileBackend::open(&img).unwrap(), "not the passphrase").unwrap_err();
assert!(matches!(err, fstool::Error::InvalidArgument(_)), "{err}");
}
fn cryptsetup_reads_our_header(version: Version, name: &str) {
if !which("cryptsetup") {
eprintln!("skipping: cryptsetup not installed");
return;
}
let dir = TempDir::new().unwrap();
let path = dir.path().join(name);
let dev = FileBackend::create(&path, 64 * 1024 * 1024).unwrap();
let opts = FormatOpts {
version,
..FormatOpts::fast_for_tests()
};
let vol = format(dev, PASSPHRASE, &opts).unwrap();
let ours = vol.master_key().as_bytes().to_vec();
let uuid = vol.header().uuid().to_owned();
drop(vol);
let dump = cryptsetup_dump(&path);
assert!(
dump.contains(&uuid),
"cryptsetup did not report our UUID:\n{dump}"
);
assert!(
dump.contains("aes-xts-plain64") || dump.contains("xts-plain64"),
"cryptsetup did not report our cipher:\n{dump}"
);
let theirs = cryptsetup_master_key(&path)
.unwrap_or_else(|| panic!("cryptsetup could not unlock our {version:?} keyslot"));
assert_eq!(theirs, ours, "{name}: cryptsetup recovered a different key");
}
#[test]
fn cryptsetup_reads_our_luks1_header() {
cryptsetup_reads_our_header(Version::V1, "ours-luks1.img");
}
#[test]
fn cryptsetup_reads_our_luks2_header() {
cryptsetup_reads_our_header(Version::V2, "ours-luks2.img");
}
#[test]
fn reads_payload_written_by_qemu_io() {
if !which("qemu-img") || !which("qemu-io") {
eprintln!("skipping: qemu-img / qemu-io not installed");
return;
}
let dir = TempDir::new().unwrap();
let path = dir.path().join("qemu.luks");
let out = Command::new("qemu-img")
.arg("create")
.arg("--object")
.arg(format!("secret,id=sec0,data={PASSPHRASE}"))
.args(["-f", "luks", "-o", "key-secret=sec0,iter-time=10"])
.arg(&path)
.arg("8M")
.output()
.expect("spawning qemu-img");
if !out.status.success() {
eprintln!(
"skipping: qemu-img cannot create LUKS here: {}",
String::from_utf8_lossy(&out.stderr)
);
return;
}
let w = qemu_io(
&path,
&["write -P 0xab 0 8192", "write -P 0x5c 1048576 4096"],
);
assert!(
w.status.success(),
"qemu-io write failed: {}",
String::from_utf8_lossy(&w.stderr)
);
let mut vol = LuksBackend::open(FileBackend::open(&path).unwrap(), PASSPHRASE).unwrap();
let mut buf = vec![0u8; 8192];
vol.read_at(0, &mut buf).unwrap();
assert!(buf.iter().all(|&b| b == 0xab), "head pattern mismatch");
let mut buf = vec![0u8; 4096];
vol.read_at(1024 * 1024, &mut buf).unwrap();
assert!(buf.iter().all(|&b| b == 0x5c), "1 MiB pattern mismatch");
}
#[test]
fn qemu_io_reads_payload_we_wrote() {
if !which("qemu-io") {
eprintln!("skipping: qemu-io not installed");
return;
}
let dir = TempDir::new().unwrap();
let path = dir.path().join("ours-payload.luks");
let dev = FileBackend::create(&path, 32 * 1024 * 1024).unwrap();
let opts = FormatOpts {
version: Version::V1,
..FormatOpts::fast_for_tests()
};
let mut vol = format(dev, PASSPHRASE, &opts).unwrap();
vol.write_at(0, &[0x31u8; 8192]).unwrap();
vol.write_at(2 * 1024 * 1024, b"fstool wrote this").unwrap();
vol.sync().unwrap();
drop(vol);
let r = qemu_io(&path, &["read -P 0x31 0 8192", "read -v 2097152 16"]);
let stdout = String::from_utf8_lossy(&r.stdout);
assert!(
r.status.success(),
"qemu-io read failed: {}\n{stdout}",
String::from_utf8_lossy(&r.stderr)
);
assert!(
stdout.contains("fstool.wrote.thi"),
"qemu-io did not see our bytes:\n{stdout}"
);
}
#[test]
fn hosts_an_ext2_filesystem() {
use fstool::fs::ext::{Ext, FormatOpts as ExtOpts};
use fstool::fs::{FileMeta, FileSource, Filesystem};
let dir = TempDir::new().unwrap();
let path = dir.path().join("fs.luks");
let dev = FileBackend::create(&path, 32 * 1024 * 1024).unwrap();
let mut vol = format(dev, PASSPHRASE, &FormatOpts::fast_for_tests()).unwrap();
let ext_opts = ExtOpts {
blocks_count: (vol.total_size() / 1024) as u32,
..ExtOpts::default()
};
let mut fs = Ext::format_with(&mut vol, &ext_opts).unwrap();
let body = b"encrypted at rest\n";
fs.create_file(
&mut vol,
Path::new("/hello.txt"),
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(body.to_vec())),
len: body.len() as u64,
},
FileMeta::default(),
)
.unwrap();
fs.flush(&mut vol).unwrap();
vol.sync().unwrap();
drop(fs);
drop(vol);
let mut vol = LuksBackend::open(FileBackend::open(&path).unwrap(), PASSPHRASE).unwrap();
let mut fs = Ext::open(&mut vol).unwrap();
let mut got = Vec::new();
{
use std::io::Read as _;
let mut r = fs.read_file(&mut vol, Path::new("/hello.txt")).unwrap();
r.read_to_end(&mut got).unwrap();
}
assert_eq!(got, body);
}