use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
const FERROSYS: &str = env!("CARGO_BIN_EXE_ferrosys");
const UUID: &str = "f0e17055-0000-4000-8000-000000000000";
const TIME: &str = "1700000000";
const E2FSPROGS_VERSION: &str = "1.47.0";
const E2FSPROGS_TOOLS: &[&str] = &["debugfs", "dumpe2fs", "e2fsck", "mke2fs", "resize2fs"];
fn tool(name: &str) -> Command {
let mut cmd = Command::new(name);
cmd.env("LC_ALL", "C");
if name == "mke2fs" {
cmd.env("MKE2FS_CONFIG", mke2fs_config());
}
cmd
}
fn mke2fs_config() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ci/mke2fs.conf")
}
const OK: i32 = 0;
const IMAGE_BAD: i32 = 4;
const OPERATIONAL: i32 = 8;
const USAGE: i32 = 16;
fn available(name: &str) -> bool {
let probe = tool(name).arg("-V").output();
let ok = probe
.as_ref()
.map(|o| o.status.success() || !o.stderr.is_empty() || !o.stdout.is_empty())
.unwrap_or(false);
if !ok {
assert!(
std::env::var_os("FERROSYS_REQUIRE_HOST_TOOLS").is_none(),
"gate requires `{name}` but it was not found on PATH"
);
eprintln!(
"\n!!! SKIPPING gate: `{name}` not found on PATH — \
this was NOT verified against a foreign implementation !!!\n"
);
return false;
}
if E2FSPROGS_TOOLS.contains(&name) {
let probe = probe.expect("probed above");
let banner = format!(
"{}{}",
String::from_utf8_lossy(&probe.stdout),
String::from_utf8_lossy(&probe.stderr)
);
let version = banner
.split_whitespace()
.skip_while(|t| *t != name)
.nth(1)
.unwrap_or("unknown");
if version != E2FSPROGS_VERSION {
assert!(
std::env::var_os("FERROSYS_REQUIRE_HOST_TOOLS").is_none(),
"the gates pin e2fsprogs {E2FSPROGS_VERSION} as their oracle, \
but `{name}` reports {version}"
);
eprintln!(
"note: `{name}` is version {version}, not the {E2FSPROGS_VERSION} the \
gates are written against — a divergence may not reproduce under CI's \
pinned oracle"
);
}
}
true
}
fn run(args: &[&str]) -> Output {
Command::new(FERROSYS)
.args(args)
.output()
.expect("the binary runs")
}
fn run_with_stdin(args: &[&str], input: &[u8]) -> Output {
let mut child = Command::new(FERROSYS)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
child
.stdin
.take()
.expect("stdin is piped")
.write_all(input)
.expect("write the input");
child.wait_with_output().expect("the binary finishes")
}
fn code(out: &Output) -> i32 {
out.status.code().expect("the process exited normally")
}
fn ok(args: &[&str]) -> Vec<u8> {
let out = run(args);
assert_eq!(
code(&out),
OK,
"`ferrosys {}` failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
fn scratch() -> tempfile::TempDir {
tempfile::tempdir().expect("a scratch directory")
}
fn at(dir: &tempfile::TempDir, name: &str) -> PathBuf {
dir.path().join(name)
}
fn format(image: &Path, size: &str, archive: Option<&Path>) -> Output {
let image = image.to_str().expect("a text path");
let mut args = vec![
"format", "--size", size, "--uuid", UUID, "--time", TIME, image,
];
let archive = archive.map(|a| a.to_str().expect("a text path").to_string());
if let Some(a) = &archive {
args.insert(1, "--from-tar");
args.insert(2, a);
}
run(&args)
}
fn fidelity_archive() -> Vec<u8> {
use tar::{Builder, EntryType, Header};
let mut b = Builder::new(Vec::new());
let push = |b: &mut Builder<Vec<u8>>,
records: Vec<(&str, Vec<u8>)>,
kind: EntryType,
path: &str,
mode: u32,
uid: u64,
gid: u64,
link: Option<&str>,
device: Option<(u32, u32)>,
data: &[u8]| {
let mut recs: Vec<(String, Vec<u8>)> = records
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
recs.push(("path".to_string(), path.as_bytes().to_vec()));
if let Some(l) = link {
recs.push(("linkpath".to_string(), l.as_bytes().to_vec()));
}
let borrowed: Vec<(&str, &[u8])> = recs
.iter()
.map(|(k, v)| (k.as_str(), v.as_slice()))
.collect();
b.append_pax_extensions(borrowed).expect("pax records");
let mut h = Header::new_ustar();
h.set_entry_type(kind);
h.set_mode(mode);
h.set_uid(uid);
h.set_gid(gid);
h.set_mtime(1_700_000_000);
let _ = h.set_path(path);
if let Some(l) = link {
let _ = h.set_link_name(l);
}
if let Some((major, minor)) = device {
h.set_device_major(major).expect("a major number");
h.set_device_minor(minor).expect("a minor number");
}
h.set_size(data.len() as u64);
h.set_cksum();
b.append(&h, data).expect("append");
};
push(
&mut b,
vec![],
EntryType::Directory,
"./",
0o755,
0,
0,
None,
None,
&[],
);
push(
&mut b,
vec![],
EntryType::Directory,
"./etc/",
0o755,
0,
0,
None,
None,
&[],
);
push(
&mut b,
vec![
("mtime", b"1700000000.123456789".to_vec()),
("atime", b"1600000000".to_vec()),
("ctime", b"1650000000".to_vec()),
("SCHILY.xattr.user.note", b"hello".to_vec()),
],
EntryType::Regular,
"./etc/hostname",
0o644,
1000,
1000,
None,
None,
b"ferrosys\n",
);
push(
&mut b,
vec![("SCHILY.xattr.user.big", vec![0xcd; 400])],
EntryType::Regular,
"./etc/big",
0o600,
0,
0,
None,
None,
&vec![b'x'; 5000],
);
push(
&mut b,
vec![
("SCHILY.xattr.system.posix_acl_access", acl_v2_access()),
("SCHILY.xattr.system.posix_acl_default", acl_v2_default()),
],
EntryType::Directory,
"./home/",
0o750,
1000,
1000,
None,
None,
&[],
);
push(
&mut b,
vec![],
EntryType::Symlink,
"./etc/mtab",
0o777,
0,
0,
Some("/proc/self/mounts"),
None,
&[],
);
let long_target = "/".to_string() + &"p".repeat(120);
push(
&mut b,
vec![],
EntryType::Symlink,
"./etc/long",
0o777,
0,
0,
Some(&long_target),
None,
&[],
);
push(
&mut b,
vec![],
EntryType::Link,
"./etc/hostname.link",
0o644,
1000,
1000,
Some("./etc/hostname"),
None,
&[],
);
push(
&mut b,
vec![],
EntryType::Directory,
"./dev/",
0o755,
0,
0,
None,
None,
&[],
);
push(
&mut b,
vec![],
EntryType::Char,
"./dev/null",
0o666,
0,
0,
None,
Some((1, 3)),
&[],
);
push(
&mut b,
vec![],
EntryType::Block,
"./dev/sda",
0o660,
0,
6,
None,
Some((8, 0)),
&[],
);
push(
&mut b,
vec![],
EntryType::Fifo,
"./dev/initctl",
0o600,
0,
0,
None,
None,
&[],
);
push(
&mut b,
vec![],
EntryType::Directory,
"./many/",
0o755,
0,
0,
None,
None,
&[],
);
for i in 0..1200 {
push(
&mut b,
vec![],
EntryType::Regular,
&format!("./many/file-{i:05}"),
0o644,
0,
0,
None,
None,
format!("{i}").as_bytes(),
);
}
b.into_inner().expect("finish the archive")
}
fn acl_v2_access() -> Vec<u8> {
vec![
0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, 0x02, 0x00, 0x06, 0x00, 0xe8, 0x03, 0x00, 0x00, 0x04, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, 0x10, 0x00, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x04, 0x00, 0xff, 0xff, 0xff, 0xff, ]
}
fn acl_v2_default() -> Vec<u8> {
vec![
0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, 0x04, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, ]
}
fn write_archive(dir: &tempfile::TempDir) -> PathBuf {
let path = at(dir, "source.tar");
std::fs::write(&path, fidelity_archive()).expect("write the archive");
path
}
#[test]
fn a_formatted_image_checks_clean_and_is_reproducible() {
let dir = scratch();
let image = at(&dir, "fs.img");
let out = format(&image, "64M", None);
assert_eq!(code(&out), OK, "{}", String::from_utf8_lossy(&out.stderr));
assert!(out.stdout.is_empty(), "nothing goes to the standard output");
assert!(String::from_utf8_lossy(&out.stderr).contains("Filesystem UUID:"));
let again = at(&dir, "again.img");
assert_eq!(code(&format(&again, "64M", None)), OK);
assert_eq!(
std::fs::read(&image).expect("read"),
std::fs::read(&again).expect("read"),
"two formats from the same inputs wrote different bytes"
);
if !available("e2fsck") {
return;
}
e2fsck_clean(&image);
}
#[test]
fn an_image_built_from_an_archive_checks_clean() {
let dir = scratch();
let archive = write_archive(&dir);
let image = at(&dir, "fs.img");
let out = format(&image, "128M", Some(&archive));
assert_eq!(code(&out), OK, "{}", String::from_utf8_lossy(&out.stderr));
if !available("e2fsck") {
return;
}
e2fsck_clean(&image);
}
fn e2fsck_clean(image: &Path) {
let out = tool("e2fsck")
.args(["-f", "-n"])
.arg(image)
.output()
.expect("spawn e2fsck");
assert!(
out.status.success(),
"e2fsck faulted the image (exit {:?})\nstdout:\n{}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn format_writes_the_selected_base_profile() {
let dir = scratch();
for profile in ["ext2", "ext3", "ext4"] {
let image = at(&dir, &format!("{profile}.img"));
let path = image.to_str().expect("a text path");
let out = run(&[
"format", "-t", profile, "--size", "64M", "--uuid", UUID, "--time", TIME, path,
]);
assert_eq!(
code(&out),
OK,
"format -t {profile} failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let summary = fields(&String::from_utf8_lossy(&out.stderr));
assert_eq!(
summary["Filesystem profile"], profile,
"the format summary names the profile it wrote"
);
let report = fields(&String::from_utf8_lossy(&ok(&["inspect", path])));
assert_eq!(
report["Filesystem profile"], profile,
"inspect labels the {profile} image as {profile}"
);
if available("e2fsck") {
e2fsck_clean(&image);
}
}
}
#[test]
fn format_refuses_a_destination_that_is_not_a_regular_file() {
let dir = scratch();
let out = format(dir.path(), "64M", None);
assert_eq!(code(&out), OPERATIONAL);
assert!(
String::from_utf8_lossy(&out.stderr).contains("not a regular file"),
"the refusal says why: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn format_takes_its_time_from_the_environment_when_the_option_is_absent() {
let dir = scratch();
let image = at(&dir, "fs.img");
let out = Command::new(FERROSYS)
.args(["format", "--size", "64M", "--uuid", UUID])
.arg(&image)
.env("SOURCE_DATE_EPOCH", TIME)
.output()
.expect("the binary runs");
assert_eq!(code(&out), OK, "{}", String::from_utf8_lossy(&out.stderr));
let explicit = at(&dir, "explicit.img");
assert_eq!(code(&format(&explicit, "64M", None)), OK);
assert_eq!(
std::fs::read(&image).expect("read"),
std::fs::read(&explicit).expect("read")
);
let neither = at(&dir, "neither.img");
let out = Command::new(FERROSYS)
.args(["format", "--size", "64M", "--uuid", UUID])
.arg(&neither)
.env_remove("SOURCE_DATE_EPOCH")
.output()
.expect("the binary runs");
assert_eq!(code(&out), USAGE);
}
fn fields(text: &str) -> std::collections::HashMap<String, String> {
text.lines()
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.collect()
}
#[test]
fn inspect_agrees_with_dumpe2fs_field_by_field() {
if !available("dumpe2fs") {
return;
}
let dir = scratch();
let archive = write_archive(&dir);
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "128M", Some(&archive))), OK);
let ours = fields(&String::from_utf8_lossy(&ok(&[
"inspect",
image.to_str().expect("a text path"),
])));
let theirs = tool("dumpe2fs")
.arg("-h")
.arg(&image)
.output()
.expect("spawn dumpe2fs");
let theirs = fields(&String::from_utf8_lossy(&theirs.stdout));
let shared = [
"Filesystem volume name",
"Filesystem UUID",
"Filesystem magic number",
"Filesystem state",
"Errors behavior",
"Filesystem OS type",
"Inode count",
"Block count",
"Reserved block count",
"Free blocks",
"Free inodes",
"First block",
"Block size",
"Group descriptor size",
"Reserved GDT blocks",
"Blocks per group",
"Inodes per group",
"Inode blocks per group",
"Flex block group size",
"First inode",
"Inode size",
"Journal inode",
"Orphan file inode",
"Default directory hash",
"Directory Hash Seed",
"Checksum type",
"Checksum seed",
];
for key in shared {
let ours = ours
.get(key)
.unwrap_or_else(|| panic!("inspect prints {key}"));
let theirs = theirs
.get(key)
.unwrap_or_else(|| panic!("dumpe2fs prints {key}"));
assert_eq!(ours, theirs, "the two tools disagree about {key}");
}
let sorted = |s: &str| {
let mut v: Vec<&str> = s.split_whitespace().collect();
v.sort_unstable();
v.join(" ")
};
let f = "Filesystem features";
assert_eq!(
sorted(&ours[f]),
sorted(&theirs[f]),
"the two tools disagree about which features the image carries"
);
}
#[test]
fn format_applies_the_label_inode_and_reserved_options() {
let dir = scratch();
let image = at(&dir, "labelled.img");
let path = image.to_str().expect("a text path");
let out = run(&[
"format",
"--size",
"256M",
"--uuid",
UUID,
"--time",
TIME,
"--label",
"rootfs",
"--inodes",
"5000",
"--reserved-percent",
"1.5",
path,
]);
assert_eq!(
code(&out),
OK,
"format failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let report = fields(&String::from_utf8_lossy(&ok(&["inspect", path])));
assert_eq!(report["Filesystem volume name"], "rootfs");
assert_eq!(report["Filesystem profile"], "ext4");
assert_eq!(report["Inode count"], "5024");
assert_eq!(report["Reserved block count"], "983");
}
#[test]
fn format_refuses_an_over_long_label_and_an_out_of_range_percent() {
let dir = scratch();
let image = at(&dir, "fs.img");
let path = image.to_str().expect("a text path");
let out = run(&[
"format",
"--size",
"64M",
"--uuid",
UUID,
"--time",
TIME,
"--label",
"0123456789abcdefX",
path,
]);
assert_eq!(code(&out), USAGE);
assert!(!image.exists(), "a refused format writes nothing");
let out = run(&[
"format",
"--size",
"64M",
"--uuid",
UUID,
"--time",
TIME,
"--reserved-percent",
"60",
path,
]);
assert_eq!(code(&out), USAGE);
}
#[test]
fn inspect_reports_every_group_and_scans_by_default() {
let dir = scratch();
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "512M", None)), OK);
let text = String::from_utf8(ok(&[
"inspect",
"--groups",
image.to_str().expect("a text path"),
]))
.expect("the report is text");
assert!(text.contains("GROUP"), "the group table has a header");
for group in 0..4 {
assert!(
text.lines().any(|l| l.starts_with(&format!("{group} "))),
"group {group} is in the table"
);
}
assert!(text.contains("no anomalies"));
}
#[test]
fn inspect_groups_survives_a_hostile_group_count() {
let dir = scratch();
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "16M", None)), OK);
let mut bytes = std::fs::read(&image).expect("read the image");
bytes[1024 + 0x04..1024 + 0x08].copy_from_slice(&u32::MAX.to_le_bytes());
bytes[1024 + 0x20..1024 + 0x24].copy_from_slice(&1u32.to_le_bytes());
std::fs::write(&image, &bytes).expect("write the image");
let out = run(&["inspect", "--groups", image.to_str().expect("a text path")]);
let exit = out
.status
.code()
.expect("the process exited rather than aborting");
assert_eq!(
exit,
IMAGE_BAD,
"a hostile group count is a bad image, not a crash:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn inspect_json_parses_as_json() {
if !available("python3") {
return;
}
let dir = scratch();
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "64M", None)), OK);
let json = ok(&[
"inspect",
"--json",
"--groups",
image.to_str().expect("a text path"),
]);
let judge = r#"
import json, sys
doc = json.load(sys.stdin)
assert doc["version"] == 1, doc["version"]
sb = doc["superblock"]
assert sb["uuid"] == "f0e17055-0000-4000-8000-000000000000", sb["uuid"]
assert sb["block_size"] == 4096, sb["block_size"]
assert sb["blocks"] * sb["block_size"] == 64 * 1024 * 1024, sb["blocks"]
assert sb["created"] == 1700000000, sb["created"]
feats = doc["features"]
assert "has_journal" in feats["compat"], feats["compat"]
assert "extent" in feats["incompat"], feats["incompat"]
assert feats["profile"] == "ext4", feats["profile"]
assert feats["unknown"] == {"compat": 0, "incompat": 0, "ro_compat": 0}, feats["unknown"]
assert doc["scan"]["clean"] is True and doc["scan"]["anomalies"] == [], doc["scan"]
groups = doc["groups"]
assert len(groups) == 1 and groups[0]["group"] == 0, groups
assert groups[0]["free_inodes"] == sb["free_inodes"], groups
"#;
let mut child = tool("python3")
.args(["-c", judge])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn python3");
child
.stdin
.take()
.expect("stdin is piped")
.write_all(&json)
.expect("write the document");
let out = child.wait_with_output().expect("python3 finishes");
assert!(
out.status.success(),
"python3 rejected the document or its values:\n{}\n{}",
String::from_utf8_lossy(&json),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn the_four_exit_codes_are_reachable_and_distinct() {
let dir = scratch();
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "64M", None)), OK);
let path = image.to_str().expect("a text path");
assert_eq!(code(&run(&["inspect", path])), OK);
let bad = at(&dir, "bad.img");
let mut bytes = std::fs::read(&image).expect("read");
let root_inode = inode_table_offset(&bytes) + 256; bytes[root_inode] ^= 0xff;
std::fs::write(&bad, &bytes).expect("write");
let out = run(&["inspect", bad.to_str().expect("a text path")]);
assert_eq!(
code(&out),
IMAGE_BAD,
"a corrupted image is bad, not merely described:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!out.stdout.is_empty());
let blob = at(&dir, "blob.img");
std::fs::write(&blob, vec![0x5a; 64 * 1024]).expect("write");
assert_eq!(
code(&run(&["inspect", blob.to_str().expect("a text path")])),
OPERATIONAL
);
assert_eq!(code(&run(&["inspect", "--nonesuch", path])), USAGE);
assert_eq!(code(&run(&["frobnicate"])), USAGE);
assert_eq!(code(&run(&[])), USAGE);
}
#[test]
fn a_filesystem_another_formatter_wrote_is_not_thereby_bad() {
if !available("mke2fs") {
return;
}
let dir = scratch();
for kind in ["ext4", "ext3", "ext2"] {
let image = at(&dir, &format!("{kind}.img"));
std::fs::write(&image, vec![0u8; 32 << 20]).expect("make the file");
let made = tool("mke2fs")
.args(["-q", "-t", kind])
.arg(&image)
.output()
.expect("spawn mke2fs");
assert!(
made.status.success(),
"mke2fs could not make an {kind} filesystem: {}",
String::from_utf8_lossy(&made.stderr)
);
let out = run(&["inspect", image.to_str().expect("a text path")]);
assert_eq!(
code(&out),
OK,
"a healthy {kind} filesystem from mke2fs was reported bad:\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stdout);
assert!(
report.contains("Block count:") && report.contains("no anomalies"),
"the {kind} filesystem was scanned:\n{report}"
);
}
}
#[test]
fn a_bad_image_is_only_bad_at_the_severity_asked_for() {
let dir = scratch();
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "64M", None)), OK);
let bad = at(&dir, "bad.img");
let mut bytes = std::fs::read(&image).expect("read");
let root_inode = inode_table_offset(&bytes) + 256;
bytes[root_inode] ^= 0xff;
std::fs::write(&bad, &bytes).expect("write");
let path = bad.to_str().expect("a text path");
assert_eq!(code(&run(&["inspect", path])), IMAGE_BAD);
let out = run(&["inspect", "--fail-on", "never", path]);
assert_eq!(code(&out), OK);
assert!(
String::from_utf8_lossy(&out.stdout).contains("integrity")
|| String::from_utf8_lossy(&out.stdout).contains("structural"),
"the findings are still reported"
);
assert_eq!(code(&run(&["inspect", "--quick", path])), OK);
}
fn inode_table_offset(bytes: &[u8]) -> usize {
let u32_at = |off: usize| {
u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) as usize
};
let block_size = 1024usize << u32_at(1024 + 0x18);
let gdt = block_size; u32_at(gdt + 8) * block_size
}
#[test]
fn a_tar_survives_a_round_trip_through_a_filesystem() {
let dir = scratch();
let archive = write_archive(&dir);
let image = at(&dir, "fs.img");
assert_eq!(
code(&format(&image, "128M", Some(&archive))),
OK,
"the archive formats"
);
let out_tar = at(&dir, "out.tar");
assert_eq!(
code(&run(&[
"extract",
image.to_str().expect("a text path"),
"--to-tar",
out_tar.to_str().expect("a text path"),
])),
OK
);
let tar_bytes = std::fs::read(&out_tar).expect("read the archive");
let contains = |needle: &[u8]| tar_bytes.windows(needle.len()).any(|w| w == needle);
assert!(
contains(b"SCHILY.xattr.system.posix_acl_access"),
"the archive carries the access ACL"
);
assert!(
contains(b"SCHILY.xattr.system.posix_acl_default"),
"the archive carries the default ACL"
);
assert!(
contains(&acl_v2_access()),
"the ACL travels in the version-2 form, not ext4's on-disk form"
);
assert!(
contains(b"SCHILY.xattr.user.big"),
"the archive carries the attribute that spilled into a block"
);
assert!(
contains(b"./etc/hostname.link"),
"the archive carries the hard link"
);
assert!(
contains(b"1700000000.123456789"),
"the archive carries the sub-second time the header cannot hold"
);
let listing = String::from_utf8(ok(&[
"extract",
image.to_str().expect("a text path"),
"--list",
]))
.expect("text");
let many = listing
.lines()
.filter(|l| l.contains("/many/file-"))
.count();
assert_eq!(
many, 1200,
"every name in the hash-indexed directory is read back"
);
let again = at(&dir, "again.img");
let out = format(&again, "128M", Some(&out_tar));
assert_eq!(
code(&out),
OK,
"the archive we wrote is one we can read back:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read(&image).expect("read"),
std::fs::read(&again).expect("read"),
"the filesystem did not survive the round trip through our own archive"
);
if !available("e2fsck") {
return;
}
e2fsck_clean(&again);
}
#[test]
fn gnu_tar_reads_the_archive_we_write() {
if !available("tar") {
return;
}
let dir = scratch();
let archive = write_archive(&dir);
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "128M", Some(&archive))), OK);
let out_tar = at(&dir, "out.tar");
assert_eq!(
code(&run(&[
"extract",
image.to_str().expect("a text path"),
"--to-tar",
out_tar.to_str().expect("a text path"),
])),
OK
);
let out = tool("tar")
.args(["--xattrs", "-tvf"])
.arg(&out_tar)
.output()
.expect("spawn tar");
let listing = String::from_utf8_lossy(&out.stdout);
let complaints = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success() && complaints.is_empty(),
"GNU tar objected to our archive:\n{complaints}"
);
for name in ["./etc/hostname", "./dev/null", "./etc/mtab"] {
assert!(listing.contains(name), "GNU tar lists {name}:\n{listing}");
}
assert!(
listing.contains("crw-rw-rw-") && listing.contains("1,3") || listing.contains("1, 3"),
"GNU tar sees the device node:\n{listing}"
);
let unpacked = at(&dir, "unpacked");
std::fs::create_dir(&unpacked).expect("make the directory");
let out = tool("tar")
.args(["--xattrs", "--xattrs-include=*", "-xf"])
.arg(&out_tar)
.arg("-C")
.arg(&unpacked)
.arg("./etc/hostname")
.output()
.expect("spawn tar");
assert!(
out.status.success(),
"GNU tar could not unpack our archive:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let hostname = unpacked.join("etc/hostname");
assert_eq!(
std::fs::read(&hostname).expect("read the unpacked file"),
b"ferrosys\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&hostname)
.expect("stat")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o644, "the mode survived into a real file");
}
if available("getfattr") {
let out = tool("getfattr")
.args(["-n", "user.note", "--only-values"])
.arg(&hostname)
.output()
.expect("spawn getfattr");
assert!(
out.status.success(),
"getfattr could not read the attribute:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"hello");
}
}
#[test]
fn extract_writes_one_artifact_and_nothing_else() {
let dir = scratch();
let archive = write_archive(&dir);
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "128M", Some(&archive))), OK);
let path = image.to_str().expect("a text path");
let out = run(&["extract", path, "--cat", "/etc/hostname"]);
assert_eq!(code(&out), OK);
assert_eq!(out.stdout, b"ferrosys\n");
assert!(out.stderr.is_empty());
let out = run(&["extract", path, "--cat", "/nowhere"]);
assert_eq!(code(&out), OPERATIONAL);
assert!(out.stdout.is_empty(), "no bytes are written for no file");
let listing = String::from_utf8(ok(&["extract", path, "--list"])).expect("text");
assert!(listing.contains("/etc/hostname"));
assert!(listing.contains("lrwxrwxrwx"), "a symlink reads as one");
assert!(
listing.contains("/etc/mtab -> /proc/self/mounts"),
"a link says where it points"
);
assert!(listing.contains("crw-rw-rw-"), "a device reads as one");
assert!(
listing.contains("/lost+found"),
"a listing describes the filesystem, and /lost+found is in it"
);
}
#[test]
fn a_pipe_carries_the_filesystem_from_one_run_to_the_next() {
let dir = scratch();
let archive = write_archive(&dir);
let image = at(&dir, "fs.img");
assert_eq!(code(&format(&image, "128M", Some(&archive))), OK);
let tar = ok(&[
"extract",
image.to_str().expect("a text path"),
"--to-tar",
"-",
]);
let piped = at(&dir, "piped.img");
let out = run_with_stdin(
&[
"format",
"--size",
"128M",
"--uuid",
UUID,
"--time",
TIME,
"--from-tar",
"-",
piped.to_str().expect("a text path"),
],
&tar,
);
assert_eq!(
code(&out),
OK,
"the piped archive formats:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read(&image).expect("read"),
std::fs::read(&piped).expect("read"),
"the filesystem did not survive the pipe"
);
}
#[test]
fn a_socket_is_a_typed_error_rather_than_a_missing_file() {
let dir = scratch();
let image = at(&dir, "fs.img");
build_image_with_socket(&image);
let out = run(&[
"extract",
image.to_str().expect("a text path"),
"--to-tar",
"-",
]);
assert_eq!(code(&out), OPERATIONAL);
let complaint = String::from_utf8_lossy(&out.stderr);
assert!(
complaint.contains("/run/sock") && complaint.contains("socket"),
"the refusal names the file and why: {complaint}"
);
}
fn build_image_with_socket(path: &Path) {
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{FormatOptions, GrowReservation, Metadata, TreeBuilder, format_to};
let time = Timestamp::from_secs(1_700_000_000);
let source = TreeBuilder::new()
.directory(b"/run".to_vec(), Metadata::new(0o755, time))
.socket(b"/run/sock".to_vec(), Metadata::new(0o666, time));
let mut options = FormatOptions::new([0x11; 16], time, [0u8; 16]);
options.grow = GrowReservation::UpTo(1 << 30);
let file = std::fs::File::create(path).expect("create the image");
format_to(source, 64 << 20, options, &file).expect("format");
}
#[test]
fn a_filesystem_inside_a_larger_image_is_read_at_its_offset() {
let dir = scratch();
let image = at(&dir, "fs.img");
let archive = write_archive(&dir);
assert_eq!(code(&format(&image, "128M", Some(&archive))), OK);
const OFFSET: usize = 1 << 20; let disk = at(&dir, "disk.img");
let mut bytes = vec![0x00; OFFSET];
bytes.extend_from_slice(&std::fs::read(&image).expect("read"));
std::fs::write(&disk, &bytes).expect("write");
let path = disk.to_str().expect("a text path");
assert_eq!(code(&run(&["inspect", path])), OPERATIONAL);
let report = String::from_utf8(ok(&["inspect", "--offset", "1M", path])).expect("text");
assert!(report.contains(UUID));
assert!(report.contains("no anomalies"));
assert_eq!(
ok(&["extract", "--offset", "1M", path, "--cat", "/etc/hostname"]),
b"ferrosys\n"
);
}
#[test]
fn help_is_an_artifact_and_a_usage_error_is_not() {
let out = run(&["--help"]);
assert_eq!(code(&out), OK);
assert!(String::from_utf8_lossy(&out.stdout).contains("usage:"));
assert!(out.stderr.is_empty());
for topic in ["format", "inspect", "extract"] {
let out = run(&[topic, "--help"]);
assert_eq!(code(&out), OK);
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains(topic), "the {topic} help names {topic}");
}
let out = run(&["format", "--help"]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("memory"),
"format's help says what --from-tar costs in memory"
);
let out = run(&["inspect", "--nonesuch", "x.img"]);
assert_eq!(code(&out), USAGE);
assert!(out.stdout.is_empty());
assert!(!out.stderr.is_empty());
}
#[test]
fn inspect_sarif_is_valid_sarif_a_foreign_parser_accepts() {
if !available("python3") {
return;
}
let dir = scratch();
let image = at(&dir, "a b#c?d%e\\f\u{e9}.img");
assert_eq!(code(&format(&image, "64M", None)), OK);
let clean = ok(&["inspect", "--sarif", image.to_str().expect("a text path")]);
check_sarif(&clean, image.to_str().expect("a text path"), 0);
{
use std::io::{Read, Seek, SeekFrom, Write};
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&image)
.expect("reopen the image");
f.seek(SeekFrom::Start(1024 + 0x30))
.expect("seek to s_wtime");
let mut byte = [0u8; 1];
f.read_exact(&mut byte).expect("read s_wtime");
byte[0] ^= 0xff;
f.seek(SeekFrom::Start(1024 + 0x30)).expect("seek back");
f.write_all(&byte).expect("corrupt s_wtime");
}
let out = run(&["inspect", "--sarif", image.to_str().expect("a text path")]);
assert_eq!(
code(&out),
IMAGE_BAD,
"a corrupted superblock is a bad image:\n{}",
String::from_utf8_lossy(&out.stderr)
);
check_sarif(&out.stdout, image.to_str().expect("a text path"), 1);
}
fn check_sarif(document: &[u8], artifact: &str, min_results: usize) {
let script = r#"
import json, os, re, sys, urllib.parse
doc = json.load(sys.stdin)
artifact, min_results = sys.argv[1], int(sys.argv[2])
# A rooted path names a file on this host, so it is located by an absolute `file://` URI;
# anything else stays the relative reference the invocation named.
expected = os.fsencode(("file://" + artifact) if artifact.startswith("/") else artifact)
# RFC 3986 3.2: a URI reference is spelled in unreserved and reserved characters and
# percent-escapes, and nothing else. A space, a backslash, or a raw non-ASCII byte is
# outside the grammar however permissive the reader.
uri_grammar = re.compile(r"(?:[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*")
assert doc["version"] == "2.1.0", doc["version"]
assert doc["$schema"].endswith("sarif-2.1.0.json"), doc["$schema"]
runs = doc["runs"]
assert len(runs) == 1, len(runs)
driver = runs[0]["tool"]["driver"]
assert driver["name"] == "ferrosys", driver["name"]
declared = {r["id"] for r in driver["rules"]}
for r in driver["rules"]:
assert r["name"] and r["shortDescription"]["text"], r
results = runs[0]["results"]
assert len(results) >= min_results, f"{len(results)} results, wanted >= {min_results}"
for r in results:
# A result naming an undeclared rule is silently dropped by an ingesting tool.
assert r["ruleId"] in declared, f'{r["ruleId"]} not in {declared}'
assert r["level"] in ("error", "warning", "note", "none"), r["level"]
assert isinstance(r["message"]["text"], str) and r["message"]["text"], r
uris = [
loc["physicalLocation"]["artifactLocation"]["uri"]
for loc in r.get("locations", [])
if "physicalLocation" in loc
]
assert len(uris) == 1, f"one artifact location per result, got {uris}"
assert uri_grammar.fullmatch(uris[0]), f"not a URI reference: {uris[0]!r}"
decoded = urllib.parse.unquote_to_bytes(uris[0])
assert decoded == expected, f"{decoded!r} != {expected!r}"
print("ok")
"#;
let mut child = tool("python3")
.args(["-c", script, artifact, &min_results.to_string()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn python3");
child
.stdin
.take()
.expect("stdin is piped")
.write_all(document)
.expect("write the document");
let out = child.wait_with_output().expect("python3 finishes");
assert!(
out.status.success(),
"python3 rejected the SARIF document:\n{}\n{}",
String::from_utf8_lossy(document),
String::from_utf8_lossy(&out.stderr)
);
}