use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub enum RootMountError {
NoRootEntry,
MalformedLine(String),
DeniedSource {
actual: String,
reason: &'static str,
},
UnexpectedSource { actual: String, expected: String },
}
impl fmt::Display for RootMountError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoRootEntry => write!(
f,
"FC-11 violation: no `/` mount entry in /proc/self/mountinfo"
),
Self::MalformedLine(line) => write!(
f,
"FC-11 violation: malformed mountinfo line for `/`: {line:?}"
),
Self::DeniedSource { actual, reason } => write!(
f,
"FC-11 violation: root mount source {actual:?} is on deny-list ({reason}) — \
indicates host filesystem leak into guest"
),
Self::UnexpectedSource { actual, expected } => write!(
f,
"FC-11 violation: root mount source is {actual:?}, expected {expected:?} \
(or `/dev/root` alias)"
),
}
}
}
impl std::error::Error for RootMountError {}
const DENIED_SOURCES: &[(&str, &str)] = &[
("overlay", "host overlay leak"),
("9p", "host bind leak"),
("virtiofs", "host bind leak"),
("rootfs", "in-memory host rootfs leak"),
];
pub fn validate_root_mount_source(
mountinfo: &str,
expected_device: &str,
) -> Result<(), RootMountError> {
let root_line = find_root_line(mountinfo).ok_or(RootMountError::NoRootEntry)?;
let (fstype, source) = parse_mount_fstype_and_source(root_line)
.ok_or_else(|| RootMountError::MalformedLine(root_line.to_string()))?;
for (denied, reason) in DENIED_SOURCES {
if fstype == *denied {
return Err(RootMountError::DeniedSource {
actual: fstype.to_string(),
reason,
});
}
if source == *denied {
return Err(RootMountError::DeniedSource {
actual: source.to_string(),
reason,
});
}
}
if source == expected_device {
return Ok(());
}
if expected_device == "/dev/vda" && source == "/dev/root" {
return Ok(());
}
Err(RootMountError::UnexpectedSource {
actual: source.to_string(),
expected: expected_device.to_string(),
})
}
fn parse_mount_fstype_and_source(line: &str) -> Option<(&str, &str)> {
let mut tokens = line.split_whitespace();
for token in tokens.by_ref() {
if token == "-" {
break;
}
}
let fstype = tokens.next()?;
let source = tokens.next()?;
Some((fstype, source))
}
fn find_root_line(mountinfo: &str) -> Option<&str> {
mountinfo.lines().find(|line| {
let mut fields = line.split_whitespace();
let mount_point = fields.nth(4);
mount_point == Some("/")
})
}
#[allow(dead_code)]
fn parse_mount_source(line: &str) -> Option<&str> {
let mut tokens = line.split_whitespace();
for token in tokens.by_ref() {
if token == "-" {
break;
}
}
let _fs_type = tokens.next()?;
tokens.next()
}
fn find_root_mount_log_offset(serial_log: &str, root_device: &str) -> Option<usize> {
let mut byte_offset = 0usize;
for line in serial_log.split_inclusive('\n') {
let basename = root_device.trim_start_matches("/dev/");
if (line.contains(root_device) || line.contains(basename))
&& (line.contains("Mounted root")
|| line.contains("mounted filesystem")
|| line.contains(" on /"))
{
return Some(byte_offset);
}
byte_offset += line.len();
}
None
}
fn find_first_cellos_init_log_offset(serial_log: &str) -> Option<usize> {
let mut byte_offset = 0usize;
for line in serial_log.split_inclusive('\n') {
if line.contains("cellos-init") {
return Some(byte_offset);
}
byte_offset += line.len();
}
None
}
pub fn assert_mount_line_before_init(serial_log: &str, root_device: &str) -> Result<(), String> {
let mount_at = find_root_mount_log_offset(serial_log, root_device).ok_or_else(|| {
format!(
"FC-11 violation: no rootfs mount line for {root_device} in serial log \
— kernel never reported the root mount"
)
})?;
let init_at = find_first_cellos_init_log_offset(serial_log).ok_or_else(|| {
"FC-11 ordering check inconclusive: no `cellos-init` line in serial log \
(separate FC-12 failure — init did not log)"
.to_string()
})?;
if mount_at >= init_at {
return Err(format!(
"FC-11 violation: rootfs mount line at byte {mount_at} appears at-or-after \
first `cellos-init` log line at byte {init_at} — root mount must precede init"
));
}
Ok(())
}
fn fixture_root_line(source: &str) -> String {
format!(
"29 1 254:0 / / rw,relatime shared:1 - ext4 {source} rw,errors=remount-ro\n\
30 29 0:5 / /dev rw,nosuid,relatime shared:2 - devtmpfs devtmpfs rw,size=65536k\n",
source = source
)
}
#[test]
fn fc11_root_mount_source_dev_vda_passes() {
let mountinfo = fixture_root_line("/dev/vda");
validate_root_mount_source(&mountinfo, "/dev/vda")
.expect("FC-11 must accept /dev/vda as root source");
}
#[test]
fn fc11_root_mount_source_dev_root_also_accepted() {
let mountinfo = fixture_root_line("/dev/root");
validate_root_mount_source(&mountinfo, "/dev/vda")
.expect("FC-11 must treat /dev/root as alias for /dev/vda");
}
#[test]
fn fc11_root_mount_source_scratch_overlay_passes() {
let mountinfo = fixture_root_line("/dev/vdb");
validate_root_mount_source(&mountinfo, "/dev/vdb")
.expect("FC-11 must accept /dev/vdb when scratch overlay is the root device");
}
#[test]
fn fc11_root_mount_source_overlay_fs_rejected() {
let mountinfo = fixture_root_line("overlay");
let err = validate_root_mount_source(&mountinfo, "/dev/vda")
.expect_err("overlay source on / must fail FC-11");
match err {
RootMountError::DeniedSource { actual, reason } => {
assert_eq!(actual, "overlay");
assert!(
reason.contains("overlay"),
"deny reason must name the leak class; got: {reason}"
);
}
other => panic!("expected DeniedSource, got {other:?}"),
}
}
#[test]
fn fc11_root_mount_source_9p_rejected() {
let mut mountinfo = String::new();
mountinfo.push_str("29 1 0:21 / / rw,relatime shared:1 - 9p host_share rw,trans=virtio\n");
let err = validate_root_mount_source(&mountinfo, "/dev/vda")
.expect_err("9p source on / must fail FC-11");
assert!(
matches!(err, RootMountError::DeniedSource { .. }),
"expected DeniedSource for 9p; got {err:?}"
);
let msg = format!("{err}");
assert!(
msg.contains("9p"),
"error must echo the offending source; got: {msg}"
);
assert!(
msg.contains("host bind leak"),
"error must classify 9p as host bind leak; got: {msg}"
);
}
#[test]
fn fc11_root_mount_line_present_before_init_log() {
let serial_log = "\
[ 0.000000] Linux version 6.1.0-cellos\n\
[ 0.123456] Command line: console=ttyS0 root=/dev/vda rw\n\
[ 0.234567] EXT4-fs (vda): mounted filesystem with ordered data mode on /\n\
[ 0.345678] VFS: Mounted root (ext4 filesystem) readonly on device 254:0.\n\
[ 0.456789] Run /sbin/init as init process\n\
[ 0.567890] cellos-init: starting (pid=1)\n\
[ 0.678901] cellos-init: drop_capabilities() complete\n";
assert_mount_line_before_init(serial_log, "/dev/vda")
.expect("mount line precedes cellos-init — FC-11 ordering must pass");
let mountinfo = fixture_root_line("/dev/vda");
validate_root_mount_source(&mountinfo, "/dev/vda")
.expect("paired mountinfo must validate alongside ordering");
}
#[test]
fn fc11_root_mount_line_after_init_log_is_rejected() {
let serial_log = "\
[ 0.100000] Linux version 6.1.0-cellos\n\
[ 0.200000] cellos-init: starting (pid=1)\n\
[ 0.300000] EXT4-fs (vda): mounted filesystem with ordered data mode on /\n";
let err = assert_mount_line_before_init(serial_log, "/dev/vda")
.expect_err("init-before-mount ordering must be rejected");
assert!(
err.contains("at-or-after"),
"error must explain ordering; got: {err}"
);
}
#[test]
fn fc11_no_root_entry_is_error() {
let mountinfo = "30 29 0:5 / /dev rw,nosuid,relatime shared:2 - devtmpfs devtmpfs rw\n";
let err =
validate_root_mount_source(mountinfo, "/dev/vda").expect_err("missing / entry must fail");
assert_eq!(err, RootMountError::NoRootEntry);
}
#[test]
fn fc11_unexpected_source_is_distinguishable_from_deny_list() {
let mountinfo = fixture_root_line("/dev/vdc");
let err = validate_root_mount_source(&mountinfo, "/dev/vda")
.expect_err("/dev/vdc when /dev/vda expected must fail");
match err {
RootMountError::UnexpectedSource { actual, expected } => {
assert_eq!(actual, "/dev/vdc");
assert_eq!(expected, "/dev/vda");
}
other => panic!("expected UnexpectedSource, got {other:?}"),
}
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires running guest + serial.log capture"]
fn fc11_root_mount_source_e2e_from_captured_mountinfo() {
let path = std::env::var("CELLOS_FIRECRACKER_FC11_MOUNTINFO_PATH").expect(
"CELLOS_FIRECRACKER_FC11_MOUNTINFO_PATH must point to a captured \
/proc/self/mountinfo file from inside the guest workload",
);
let mountinfo = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"failed to read captured mountinfo at {path}: {e} \
(expected the e2e workflow to drop the capture before invoking this test)"
)
});
let candidates = ["/dev/vda", "/dev/vdb"];
let mut last_err: Option<RootMountError> = None;
for expected in candidates {
match validate_root_mount_source(&mountinfo, expected) {
Ok(()) => return,
Err(e @ RootMountError::DeniedSource { .. }) => {
panic!(
"FC-11 acceptance failed for capture at {path}: {e}\n\
----- captured mountinfo -----\n{mountinfo}\n----- end -----"
);
}
Err(other) => last_err = Some(other),
}
}
panic!(
"FC-11 acceptance failed for capture at {path}: root device not in \
acceptance set {{/dev/vda, /dev/root, /dev/vdb}}; last error: {:?}\n\
----- captured mountinfo -----\n{mountinfo}\n----- end -----",
last_err
);
}