From 29fca59317ffb67d8a3c89061faddf224c086051 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 7 Nov 2026 21:57:33 -0800 Subject: [PATCH] guest: cold-boot a retained layered root Prepare the root, runtime mounts, view and account through their canonical owners before one switch. Keep control, user/home and mapping handles for the later dispatcher, and require complete mappings before workloads start. Keep the existing timeout regression deterministic by waiting for its real attached fixture child before the unchanged workload deadline and teardown assertions. Refuse final home-directory symlinks even with trailing slashes by normalizing only the lookup path. Preserve the root path, ordinary ancestor resolution and original account home bytes; cover refusal, account repair and unchanged usable-directory defaults. Distinguish a host export from a single live share by its aggregate marker, not just their possibly identical mount target. Keep strict export validation and positive-MAPS requirements while accepting the existing live-share form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a5e14ef9-1791-4b27-b60e-d3efd0e62e16 --- nvx-rs/src/guest/account.rs | 213 ++++++++++++++ nvx-rs/src/guest/account/tests.rs | 84 ++++++ nvx-rs/src/guest/entry.rs | 210 +------------- nvx-rs/src/guest/entry/tests.rs | 82 +----- nvx-rs/src/guest/init.rs | 66 +++-- nvx-rs/src/guest/init/boot.rs | 327 +++++++++++++++++++++ nvx-rs/src/guest/init/boot/account.rs | 120 ++++++++ nvx-rs/src/guest/init/boot/tests.rs | 402 ++++++++++++++++++++++++++ nvx-rs/src/guest/init/tests.rs | 19 ++ nvx-rs/src/guest/mod.rs | 1 + nvx-rs/src/guest/root.rs | 2 - nvx-rs/src/guest/sys/rooted.rs | 87 ++++++ nvx-rs/src/guest/sys/rooted/tests.rs | 88 ++++++ scripts/test-layout-allowlist.json | 2 +- scripts/test_adversarial.py | 78 +++-- 15 files changed, 1448 insertions(+), 333 deletions(-) create mode 100644 nvx-rs/src/guest/account.rs create mode 100644 nvx-rs/src/guest/account/tests.rs create mode 100644 nvx-rs/src/guest/init/boot.rs create mode 100644 nvx-rs/src/guest/init/boot/account.rs create mode 100644 nvx-rs/src/guest/init/boot/tests.rs diff --git a/nvx-rs/src/guest/account.rs b/nvx-rs/src/guest/account.rs new file mode 100644 index 000000000..966e463f8 --- /dev/null +++ b/nvx-rs/src/guest/account.rs @@ -0,0 +1,213 @@ +//! Bounded account databases below an already-open root, independent of execution entry. + +use std::fmt; +use std::io::{self, Read}; + +use super::parse::parse_decimal; +use super::sys::{self, Errno, FileDescriptor, Mode, OpenFlags}; + +/// The largest account database, `/etc/passwd` or `/etc/group`, that existing-account mode reads. +pub const MAX_ACCOUNT_DATABASE_BYTES: u64 = 1 << 20; +/// The path of the passwd database below the root. +const PASSWD: &std::ffi::CStr = c"etc/passwd"; +/// The path of the group database below the root. +const GROUP: &std::ffi::CStr = c"etc/group"; + +/// The account of an identity, as its passwd entry names it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Account { + /// The user name. + name: Vec, + /// The home directory. + home: Vec, +} + +impl Account { + /// Returns the user name, as bytes, since passwd entries need not be UTF-8. + pub fn name(&self) -> &[u8] { + &self.name + } + + /// Returns the home directory, an absolute path. + pub fn home(&self) -> &[u8] { + &self.home + } +} + +/// An account database of existing-account mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Database { + /// `/etc/passwd`. + Passwd, + /// `/etc/group`. + Group, +} + +impl fmt::Display for Database { + /// Writes the database's path in the root. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Passwd => "/etc/passwd", + Self::Group => "/etc/group", + }) + } +} + +/// Why existing-account mode refused an identity. +#[derive(Debug)] +#[non_exhaustive] +pub enum AccountError { + /// The database cannot be opened or read below the root, which a symbolic link on its path + /// fails too. + Read(Database, io::Error), + /// The database is not a regular file. + NotFile(Database), + /// The database is larger than [`MAX_ACCOUNT_DATABASE_BYTES`]. + TooLarge(Database), + /// No passwd entry has the user ID. + Missing, + /// Several passwd entries have the user ID. + Duplicate, + /// The passwd entry of the user ID lacks a field, or a name. + Malformed, + /// The passwd entry's primary group, this group ID, is not the identity's. + PrimaryGroup(u32), + /// No group entry has the group ID. + Group, + /// The passwd entry's home is not absolute. + Home, +} + +impl fmt::Display for AccountError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read(database, error) => write!(formatter, "cannot read {database}: {error}"), + Self::NotFile(database) => write!(formatter, "{database} is not a regular file"), + Self::TooLarge(database) => write!( + formatter, + "{database} is larger than {MAX_ACCOUNT_DATABASE_BYTES} bytes" + ), + Self::Missing => formatter.write_str("no passwd entry has the user ID"), + Self::Duplicate => formatter.write_str("several passwd entries have the user ID"), + Self::Malformed => formatter.write_str("the passwd entry of the user ID is malformed"), + Self::PrimaryGroup(gid) => write!( + formatter, + "the passwd entry's primary group is {gid}, not the identity's" + ), + Self::Group => formatter.write_str("no group entry has the group ID"), + Self::Home => formatter.write_str("the passwd entry's home is not absolute"), + } + } +} + +impl std::error::Error for AccountError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read(_, error) => Some(error), + _ => None, + } + } +} + +/// Reads the account databases below `root` and checks the account of `uid` and `gid`. +pub(crate) fn existing_account( + root: &FileDescriptor, + uid: u32, + gid: u32, +) -> Result { + let passwd = read_database(root, Database::Passwd)?; + let account = passwd_account(&passwd, uid, gid)?; + let group = read_database(root, Database::Group)?; + if !has_group(&group, gid) { + return Err(AccountError::Group); + } + Ok(account) +} + +/// Reads `database` below `root`, through no symbolic link, if it is a regular file of at most +/// [`MAX_ACCOUNT_DATABASE_BYTES`]. +pub(crate) fn read_database( + root: &FileDescriptor, + database: Database, +) -> Result, AccountError> { + let path = match database { + Database::Passwd => PASSWD, + Database::Group => GROUP, + }; + let read = |error: Errno| AccountError::Read(database, error.into()); + // A FIFO must not block the open, which the file's kind then refuses. + let file = sys::open_beneath( + root, + path, + OpenFlags::O_RDONLY | OpenFlags::O_NONBLOCK | OpenFlags::O_NOCTTY, + Mode::empty(), + true, + ) + .map_err(read)?; + let (regular, size) = sys::file_size(&file).map_err(read)?; + if !regular { + return Err(AccountError::NotFile(database)); + } + if size > MAX_ACCOUNT_DATABASE_BYTES { + return Err(AccountError::TooLarge(database)); + } + let mut bytes = Vec::new(); + file.take(MAX_ACCOUNT_DATABASE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| AccountError::Read(database, error))?; + if bytes.len() as u64 > MAX_ACCOUNT_DATABASE_BYTES { + return Err(AccountError::TooLarge(database)); + } + Ok(bytes) +} + +/// Returns the fields of each entry of an account database, with the ID in its third field: each +/// line that names one. As the C library's files database does, it skips blank lines and +/// comments, whose first character after blanks is `#`. +pub(crate) fn entries(database: &[u8]) -> impl Iterator)> { + database + .split(|&byte| byte == b'\n') + .filter(|line| !matches!(line.trim_ascii_start().first(), None | Some(b'#'))) + .filter_map(|line| { + let fields: Vec<&[u8]> = line.split(|&byte| byte == b':').collect(); + let id = parse_decimal(fields.get(2)?)?; + Some((id, fields)) + }) +} + +/// Finds the one passwd entry of `uid` in `passwd`, and checks that its primary group is `gid` +/// and its home absolute. +fn passwd_account(passwd: &[u8], uid: u32, gid: u32) -> Result { + let mut matching = entries(passwd).filter(|(id, _)| *id == u64::from(uid)); + let (_, fields) = matching.next().ok_or(AccountError::Missing)?; + if matching.next().is_some() { + return Err(AccountError::Duplicate); + } + let [name, _, _, primary, _, home, _] = fields[..] else { + return Err(AccountError::Malformed); + }; + if name.is_empty() { + return Err(AccountError::Malformed); + } + let primary = parse_decimal(primary) + .and_then(|primary| u32::try_from(primary).ok()) + .ok_or(AccountError::Malformed)?; + if primary != gid { + return Err(AccountError::PrimaryGroup(primary)); + } + if home.first() != Some(&b'/') { + return Err(AccountError::Home); + } + Ok(Account { + name: name.to_vec(), + home: home.to_vec(), + }) +} + +/// Returns whether `group` holds an entry of `gid`. +pub(crate) fn has_group(group: &[u8], gid: u32) -> bool { + entries(group).any(|(id, _)| id == u64::from(gid)) +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/nvx-rs/src/guest/account/tests.rs b/nvx-rs/src/guest/account/tests.rs new file mode 100644 index 000000000..2367bbda5 --- /dev/null +++ b/nvx-rs/src/guest/account/tests.rs @@ -0,0 +1,84 @@ +//! Tests of the account database parsers shared by rooted account readers. + +use super::*; + +/// The passwd database of the tests' roots. +pub(crate) const PASSWD_DATABASE: &[u8] = b"root:x:0:0:root:/root:/bin/sh\n\ +# A comment names no account.\n\ +\n\ +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n\ +workload:x:1000:1001::/home/workload:/bin/sh\n"; +/// The group database of the tests' roots. +pub(crate) const GROUP_DATABASE: &[u8] = b"root:x:0:\nworkload:x:1001:\nnogroup:x:65534:\n"; + +#[test] +fn passwd_entries_are_parsed_and_checked() { + let account = passwd_account(PASSWD_DATABASE, 65534, 65534).unwrap(); + assert_eq!( + (account.name(), account.home()), + (b"nobody".as_slice(), b"/nonexistent".as_slice()) + ); + let account = passwd_account(PASSWD_DATABASE, 1000, 1001).unwrap(); + assert_eq!(account.home(), b"/home/workload"); + assert!(matches!( + passwd_account(PASSWD_DATABASE, 1001, 1001), + Err(AccountError::Missing) + )); + assert!(matches!( + passwd_account(b"", 0, 0), + Err(AccountError::Missing) + )); + let duplicate = [PASSWD_DATABASE, b"other:x:1000:1001::/home/other:/bin/sh\n"].concat(); + assert!(matches!( + passwd_account(&duplicate, 1000, 1001), + Err(AccountError::Duplicate) + )); + // Leading zeros are decimal, as the shell init's comparison read them. + let padded = b"padded:x:0065534:65534::/home/padded:/bin/sh"; + assert!(passwd_account(padded, 65534, 65534).is_ok()); + assert!(matches!( + passwd_account(PASSWD_DATABASE, 1000, 1000), + Err(AccountError::PrimaryGroup(1001)) + )); + for malformed in [ + b"short:x:1000:1001::/home/short".as_slice(), + b"long:x:1000:1001::/home/long:/bin/sh:extra", + b":x:1000:1001::/home/nameless:/bin/sh", + b"group:x:1000:not-a-number::/home/group:/bin/sh", + b"group:x:1000:-1::/home/group:/bin/sh", + b"group:x:1000:4294967296::/home/group:/bin/sh", + ] { + assert!( + matches!( + passwd_account(malformed, 1000, 1001), + Err(AccountError::Malformed) + ), + "{}", + String::from_utf8_lossy(malformed) + ); + } + for home in [b"".as_slice(), b"relative/home", b"~"] { + let line = [b"workload:x:1000:1001::".as_slice(), home, b":/bin/sh"].concat(); + assert!(matches!( + passwd_account(&line, 1000, 1001), + Err(AccountError::Home) + )); + } + // Blank lines, comments, and lines whose third field is no ID name no account. + assert!(matches!( + passwd_account( + b"# 1000:1000:1000\n #x:x:1000:1000::/home/x:/bin/sh\n\n+::::::\n", + 1000, + 1000 + ), + Err(AccountError::Missing) + )); +} + +#[test] +fn group_entries_are_found_by_id() { + assert!(has_group(GROUP_DATABASE, 1001)); + assert!(has_group(GROUP_DATABASE, 65534)); + assert!(!has_group(GROUP_DATABASE, 1000)); + assert!(!has_group(b"# x:x:1000:\n\n", 1000)); +} diff --git a/nvx-rs/src/guest/entry.rs b/nvx-rs/src/guest/entry.rs index b63cb58db..012d8faa2 100644 --- a/nvx-rs/src/guest/entry.rs +++ b/nvx-rs/src/guest/entry.rs @@ -122,18 +122,19 @@ //! ``` use std::fmt; -use std::io::{self, Read}; +use std::io; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; +use super::account::existing_account; +pub use super::account::{Account, AccountError, Database, MAX_ACCOUNT_DATABASE_BYTES}; use super::mappings::{ self, Kernel, Kind, Mapped, MappingError, SystemKernel, TargetRoot, TeardownError, }; -use super::parse::parse_decimal; use super::root::{TIME_STATUS_TARGET, bind_file_retained, bind_runtime_file}; #[cfg(doc)] use super::spawn::{Command, LaunchStage}; -use super::sys::{self, EntryChild, Errno, FileDescriptor, Mode, OpenFlags}; +use super::sys::{self, EntryChild, Mode, OpenFlags}; use super::view::{MACHINE_ID_TARGET, RESOLVER_TARGET}; #[cfg(doc)] use crate::protocol::cmdline::write_resolver; @@ -142,12 +143,6 @@ pub use super::view::{DEFAULT_HOSTNAME, MAX_HOSTNAME_BYTES, MachineId}; /// The most supplementary groups that an identity holds: Linux's `NGROUPS_MAX`. pub const MAX_GROUPS: usize = 65536; -/// The largest account database, `/etc/passwd` or `/etc/group`, that existing-account mode reads. -pub const MAX_ACCOUNT_DATABASE_BYTES: u64 = 1 << 20; -/// The path of the passwd database below the root. -const PASSWD: &std::ffi::CStr = c"etc/passwd"; -/// The path of the group database below the root. -const GROUP: &std::ffi::CStr = c"etc/group"; /// The ID that `setresuid` and `setresgid` read as "unchanged", which no identity may hold. const UNCHANGED_ID: u32 = u32::MAX; /// The names of the Linux capabilities, by number. @@ -650,9 +645,10 @@ impl Entry { ) .map_err(|error| Error::Root(error.into()))?; let account = match self.identity.account { - AccountMode::Existing => { - Some(existing_account(&directory, &self.identity).map_err(Error::Account)?) - } + AccountMode::Existing => Some( + existing_account(&directory, self.identity.uid, self.identity.gid) + .map_err(Error::Account)?, + ), AccountMode::Numeric => None, }; Ok(Prepared { @@ -814,102 +810,6 @@ impl Prepared { } } -/// The account of an identity, as its passwd entry names it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Account { - /// The user name. - name: Vec, - /// The home directory. - home: Vec, -} - -impl Account { - /// Returns the user name, as bytes, since passwd entries need not be UTF-8. - pub fn name(&self) -> &[u8] { - &self.name - } - - /// Returns the home directory, an absolute path. - pub fn home(&self) -> &[u8] { - &self.home - } -} - -/// An account database of existing-account mode. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Database { - /// `/etc/passwd`. - Passwd, - /// `/etc/group`. - Group, -} - -impl fmt::Display for Database { - /// Writes the database's path in the root. - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::Passwd => "/etc/passwd", - Self::Group => "/etc/group", - }) - } -} - -/// Why existing-account mode refused an identity. -#[derive(Debug)] -#[non_exhaustive] -pub enum AccountError { - /// The database cannot be opened or read below the root, which a symbolic link on its path - /// fails too. - Read(Database, io::Error), - /// The database is not a regular file. - NotFile(Database), - /// The database is larger than [`MAX_ACCOUNT_DATABASE_BYTES`]. - TooLarge(Database), - /// No passwd entry has the user ID. - Missing, - /// Several passwd entries have the user ID. - Duplicate, - /// The passwd entry of the user ID lacks a field, or a name. - Malformed, - /// The passwd entry's primary group, this group ID, is not the identity's. - PrimaryGroup(u32), - /// No group entry has the group ID. - Group, - /// The passwd entry's home is not absolute. - Home, -} - -impl fmt::Display for AccountError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Read(database, error) => write!(formatter, "cannot read {database}: {error}"), - Self::NotFile(database) => write!(formatter, "{database} is not a regular file"), - Self::TooLarge(database) => write!( - formatter, - "{database} is larger than {MAX_ACCOUNT_DATABASE_BYTES} bytes" - ), - Self::Missing => formatter.write_str("no passwd entry has the user ID"), - Self::Duplicate => formatter.write_str("several passwd entries have the user ID"), - Self::Malformed => formatter.write_str("the passwd entry of the user ID is malformed"), - Self::PrimaryGroup(gid) => write!( - formatter, - "the passwd entry's primary group is {gid}, not the identity's" - ), - Self::Group => formatter.write_str("no group entry has the group ID"), - Self::Home => formatter.write_str("the passwd entry's home is not absolute"), - } - } -} - -impl std::error::Error for AccountError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Read(_, error) => Some(error), - _ => None, - } - } -} - /// Why an entry refused to prepare an execution. #[derive(Debug)] #[non_exhaustive] @@ -1000,99 +900,5 @@ impl std::error::Error for Error { } } -/// Reads the account databases below `root` and checks `identity`'s account, as -/// [`AccountMode::Existing`] describes. -fn existing_account(root: &FileDescriptor, identity: &Identity) -> Result { - let passwd = read_database(root, Database::Passwd)?; - let account = passwd_account(&passwd, identity.uid, identity.gid)?; - let group = read_database(root, Database::Group)?; - if !has_group(&group, identity.gid) { - return Err(AccountError::Group); - } - Ok(account) -} - -/// Reads `database` below `root`, through no symbolic link, if it is a regular file of at most -/// [`MAX_ACCOUNT_DATABASE_BYTES`]. -fn read_database(root: &FileDescriptor, database: Database) -> Result, AccountError> { - let path = match database { - Database::Passwd => PASSWD, - Database::Group => GROUP, - }; - let read = |error: Errno| AccountError::Read(database, error.into()); - // A FIFO must not block the open, which the file's kind then refuses. - let file = sys::open_beneath( - root, - path, - OpenFlags::O_RDONLY | OpenFlags::O_NONBLOCK | OpenFlags::O_NOCTTY, - Mode::empty(), - true, - ) - .map_err(read)?; - let (regular, size) = sys::file_size(&file).map_err(read)?; - if !regular { - return Err(AccountError::NotFile(database)); - } - if size > MAX_ACCOUNT_DATABASE_BYTES { - return Err(AccountError::TooLarge(database)); - } - let mut bytes = Vec::new(); - file.take(MAX_ACCOUNT_DATABASE_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| AccountError::Read(database, error))?; - if bytes.len() as u64 > MAX_ACCOUNT_DATABASE_BYTES { - return Err(AccountError::TooLarge(database)); - } - Ok(bytes) -} - -/// Returns the fields of each entry of an account database, with the ID in its third field: each -/// line that names one. As the C library's files database does, it skips blank lines and -/// comments, whose first character after blanks is `#`. -fn entries(database: &[u8]) -> impl Iterator)> { - database - .split(|&byte| byte == b'\n') - .filter(|line| !matches!(line.trim_ascii_start().first(), None | Some(b'#'))) - .filter_map(|line| { - let fields: Vec<&[u8]> = line.split(|&byte| byte == b':').collect(); - let id = parse_decimal(fields.get(2)?)?; - Some((id, fields)) - }) -} - -/// Finds the one passwd entry of `uid` in `passwd`, and checks that its primary group is `gid` -/// and its home absolute. -fn passwd_account(passwd: &[u8], uid: u32, gid: u32) -> Result { - let mut matching = entries(passwd).filter(|(id, _)| *id == u64::from(uid)); - let (_, fields) = matching.next().ok_or(AccountError::Missing)?; - if matching.next().is_some() { - return Err(AccountError::Duplicate); - } - let [name, _, _, primary, _, home, _] = fields[..] else { - return Err(AccountError::Malformed); - }; - if name.is_empty() { - return Err(AccountError::Malformed); - } - let primary = parse_decimal(primary) - .and_then(|primary| u32::try_from(primary).ok()) - .ok_or(AccountError::Malformed)?; - if primary != gid { - return Err(AccountError::PrimaryGroup(primary)); - } - if home.first() != Some(&b'/') { - return Err(AccountError::Home); - } - Ok(Account { - name: name.to_vec(), - home: home.to_vec(), - }) -} - -/// Returns whether `group` holds an entry of `gid`. -fn has_group(group: &[u8], gid: u32) -> bool { - entries(group).any(|(id, _)| id == u64::from(gid)) -} - #[cfg(test)] mod tests; diff --git a/nvx-rs/src/guest/entry/tests.rs b/nvx-rs/src/guest/entry/tests.rs index fcd12a7e1..ac5bba7ba 100644 --- a/nvx-rs/src/guest/entry/tests.rs +++ b/nvx-rs/src/guest/entry/tests.rs @@ -13,6 +13,7 @@ use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::PermissionsExt; use super::*; +use crate::guest::account::tests::{GROUP_DATABASE, PASSWD_DATABASE}; use crate::guest::mappings::{Access, Step}; use crate::guest::root::tests::{Call, FakeKernel, Operation, attach_calls}; use crate::guest::spawn::{Command, LaunchFailure, LaunchStage}; @@ -40,15 +41,6 @@ impl Prepared { } } -/// The passwd database of the tests' roots. -const PASSWD_DATABASE: &[u8] = b"root:x:0:0:root:/root:/bin/sh\n\ -# A comment names no account.\n\ -\n\ -nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n\ -workload:x:1000:1001::/home/workload:/bin/sh\n"; -/// The group database of the tests' roots. -const GROUP_DATABASE: &[u8] = b"root:x:0:\nworkload:x:1001:\nnogroup:x:65534:\n"; - /// Returns an entry of a root at `/run/nvx/rootfs` as `identity`, whose account is numeric, so /// that checking it reads nothing. fn entry(identity: Identity) -> Entry { @@ -281,78 +273,6 @@ fn invalid_roots_ids_groups_and_hostnames_are_refused() { ); } -#[test] -fn passwd_entries_are_parsed_and_checked() { - let account = passwd_account(PASSWD_DATABASE, 65534, 65534).unwrap(); - assert_eq!( - (account.name(), account.home()), - (b"nobody".as_slice(), b"/nonexistent".as_slice()) - ); - let account = passwd_account(PASSWD_DATABASE, 1000, 1001).unwrap(); - assert_eq!(account.home(), b"/home/workload"); - assert!(matches!( - passwd_account(PASSWD_DATABASE, 1001, 1001), - Err(AccountError::Missing) - )); - assert!(matches!( - passwd_account(b"", 0, 0), - Err(AccountError::Missing) - )); - let duplicate = [PASSWD_DATABASE, b"other:x:1000:1001::/home/other:/bin/sh\n"].concat(); - assert!(matches!( - passwd_account(&duplicate, 1000, 1001), - Err(AccountError::Duplicate) - )); - // Leading zeros are decimal, as the shell init's comparison read them. - let padded = b"padded:x:0065534:65534::/home/padded:/bin/sh"; - assert!(passwd_account(padded, 65534, 65534).is_ok()); - assert!(matches!( - passwd_account(PASSWD_DATABASE, 1000, 1000), - Err(AccountError::PrimaryGroup(1001)) - )); - for malformed in [ - b"short:x:1000:1001::/home/short".as_slice(), - b"long:x:1000:1001::/home/long:/bin/sh:extra", - b":x:1000:1001::/home/nameless:/bin/sh", - b"group:x:1000:not-a-number::/home/group:/bin/sh", - b"group:x:1000:-1::/home/group:/bin/sh", - b"group:x:1000:4294967296::/home/group:/bin/sh", - ] { - assert!( - matches!( - passwd_account(malformed, 1000, 1001), - Err(AccountError::Malformed) - ), - "{}", - String::from_utf8_lossy(malformed) - ); - } - for home in [b"".as_slice(), b"relative/home", b"~"] { - let line = [b"workload:x:1000:1001::".as_slice(), home, b":/bin/sh"].concat(); - assert!(matches!( - passwd_account(&line, 1000, 1001), - Err(AccountError::Home) - )); - } - // Blank lines, comments, and lines whose third field is no ID name no account. - assert!(matches!( - passwd_account( - b"# 1000:1000:1000\n #x:x:1000:1000::/home/x:/bin/sh\n\n+::::::\n", - 1000, - 1000 - ), - Err(AccountError::Missing) - )); -} - -#[test] -fn group_entries_are_found_by_id() { - assert!(has_group(GROUP_DATABASE, 1001)); - assert!(has_group(GROUP_DATABASE, 65534)); - assert!(!has_group(GROUP_DATABASE, 1000)); - assert!(!has_group(b"# x:x:1000:\n\n", 1000)); -} - #[test] fn existing_accounts_are_read_below_the_root() { let root = root_with(Some(PASSWD_DATABASE), Some(GROUP_DATABASE)); diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index f191a8284..0956f7942 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -7,6 +7,9 @@ //! To hand PID 1 over to `nvx-init`, the early boot replaces itself with it, so that `nvx-init` //! inherits both helpers as its children, and passes it their process IDs: `TIME_DAEMON`, and //! `ANNOUNCER` when the announcer runs. Each is a decimal process ID above 1, and the two differ. +//! It reads the layered root, decoded view, shares and mapping count from the kernel command +//! line, mounts the root's runtime with the time status, and switches once. The control terminal, +//! mapping descriptors, table and resolved account defaults stay open across that switch. //! As PID 1, `nvx-init`: //! //! - reads `SIGCHLD` and `SIGTERM` from a signal descriptor. `SIGCHLD` wakes it to collect the @@ -46,6 +49,8 @@ use super::sys::{ SignalDescriptor, kernel, }; +mod boot; + /// The process ID of the init process. const INIT_PROCESS: i32 = 1; /// The VM's exit status after a failure of PID 1 or the end of a helper: that of @@ -836,10 +841,6 @@ pub(crate) struct Runtime { terminating: bool, } -#[cfg_attr( - not(test), - expect(dead_code, reason = "SL7.6b services the init runtime") -)] impl Runtime { pub(crate) fn new(arguments: &[CString]) -> Result { let mut system = RealSystem { signals: None }; @@ -852,6 +853,10 @@ impl Runtime { } /// Poll alongside control input so a blocked peer cannot hide a helper's end. + #[cfg_attr( + not(test), + expect(dead_code, reason = "SL7.6b polls the init runtime with control input") + )] pub(crate) fn wake(&self) -> Pollable<'_> { Pollable::Signals(self.system.signals.as_ref().expect("listening")) } @@ -881,17 +886,38 @@ pub fn main() -> ExitCode { return ExitStatus::Usage.into(); } let arguments = sys::args(); - init( - &mut RealSystem { signals: None }, - arguments.get(1..).unwrap_or_default(), - ); + match Runtime::new(arguments.get(1..).unwrap_or_default()) { + Ok(mut runtime) => { + let result = serve_layered(&mut runtime); + finish(&mut runtime.system, result); + } + Err(failure) => finish(&mut RealSystem { signals: None }, Err(failure)), + } ExitStatus::Failure.into() } -/// Serves as PID 1 with the helpers that `arguments` name until the VM must end, and then ends -/// it: with 0 after `SIGTERM`, or with [`FAILURE`] after the diagnostic of a failure. -fn init(system: &mut impl System, arguments: &[CString]) { - let status = match serve(system, arguments) { +fn serve_layered(runtime: &mut Runtime) -> Result<(), String> { + let command_line = std::fs::read("/proc/cmdline") + .map_err(|error| format!("cannot read the boot command line: {error}"))?; + let boot = boot::cold_boot(&command_line).map_err(|error| error.to_string())?; + runtime.system.report( + format!( + "NVX-INIT-ROOT: switched mappings={}\n", + boot.table.expected() + ) + .as_bytes(), + ); + loop { + if runtime.service(|_, _| {})? { + return Ok(()); + } + runtime.terminating |= runtime.system.wait_signal() == Some(Signal::SIGTERM); + } +} + +/// Keeps diagnostics, sync and direct power-off with the original PID-1 owner. +fn finish(system: &mut impl System, result: Result<(), String>) { + let status = match result { Ok(()) => 0, Err(failure) => { system.report(format!("NVX-INIT-ERROR: {failure}\n").as_bytes()); @@ -947,22 +973,6 @@ fn collect( Ok(()) } -/// Serves the idle PID-1 path through the same status collector as the control loop. -fn serve(system: &mut impl System, arguments: &[CString]) -> Result<(), String> { - let helpers = prepare(system, arguments)?; - let mut terminating = false; - loop { - collect(system, helpers, |_, _| {})?; - if terminating { - return Ok(()); - } - // The descriptor returns the lower of two pending signals first, `SIGTERM` before - // `SIGCHLD`: a helper may have ended just before `SIGTERM` came, so PID 1 collects once - // more before it accepts `SIGTERM`. - terminating = system.wait_signal() == Some(Signal::SIGTERM); - } -} - /// Describes the end of the helper `name`, process `process`, with `status`. fn ended(name: &str, process: ProcessId, status: ChildStatus) -> String { let how = match status { diff --git a/nvx-rs/src/guest/init/boot.rs b/nvx-rs/src/guest/init/boot.rs new file mode 100644 index 000000000..2e66abc53 --- /dev/null +++ b/nvx-rs/src/guest/init/boot.rs @@ -0,0 +1,327 @@ +//! The fixed cold-boot sequence of the VM's shared layered root. + +use std::ffi::{CString, OsStr}; +use std::net::IpAddr; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::activation::{self, View}; +use crate::gpt::Expected; +use crate::guest::mappings::{Access, Mapper, MappingTable, Reserved, SourceRoot, TargetRoot}; +use crate::guest::root::{self, Assembly, Device, Root, ShareChild, ShareKind}; +use crate::guest::sys::FileDescriptor; +use crate::guest::terminal::Terminal; +use crate::guest::view; +use crate::protocol::cmdline::{self, Token}; +use crate::protocol::export::{EXPORT_DIRECTORY, HostExport}; +use crate::protocol::root::{ + DISTRO_MMIO_BASE, ImageFormat, RootLayout, RootUpper, SCRATCH_MMIO_BASE, +}; +use crate::protocol::{self, ProtocolError}; +use crate::{Error, Result}; + +mod account; + +/// The real switched root and the descriptors and account defaults its control caller inherits. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL7.6b takes the initialized layered control handoff" + ) +)] +pub(super) struct Layered { + pub(super) root: Root, + pub(super) command_line: Vec, + pub(super) control: FileDescriptor, + pub(super) user: CString, + pub(super) home: CString, + pub(super) table: MappingTable, + pub(super) mapper: Mapper, + pub(super) export: Option, +} + +/// Initializes every fixed input before the VM's only switch; MAPS uses the retained handles. +pub(super) fn cold_boot(command_line: &[u8]) -> Result { + let tokens = Tokens::parse(command_line)?; + let mut root = root::assemble(&tokens.assembly).map_err(system_error)?; + let export = match &tokens.filesystem { + Filesystem::Export(export) => Some( + root.mount_export(&export.tag, export.mode.into()) + .map_err(system_error)?, + ), + _ => None, + }; + // The /run tmpfs must exist before a live share can be placed beneath it. + root.mount_runtime(Some(Path::new("/run/nvx/time"))) + .map_err(system_error)?; + if let Filesystem::Shared { + tag, + target, + access, + children, + } = &tokens.filesystem + { + if children.is_empty() { + root.mount_share(tag, target, *access) + .map_err(system_error)?; + } else { + root.mount_aggregate(tag, *access, children) + .map_err(system_error)?; + } + } + let mut target = TargetRoot::open(root.path()).map_err(system_error)?; + target + .add_mount(&root.path().join("run")) + .map_err(system_error)?; + let mapper = Mapper::new(target, Reserved::container_root()); + let (user, home) = account::resolve(root.path(), tokens.identity, tokens.create_account)?; + let control = Terminal::open(&tokens.control).map_err(system_error)?; + control.configure_raw().map_err(system_error)?; + view::apply(&mut root, &tokens.view, tokens.dns)?; + root.switch().map_err(system_error)?; + Ok(Layered { + root, + command_line: command_line.to_vec(), + control: control.into_descriptor(), + user, + home, + table: tokens.table, + mapper, + export, + }) +} + +fn system_error(error: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::io(format!("failed to boot the layered root: {error}")).with_source(error) +} + +/// The boot inputs after the existing root and view codecs have checked them. +struct Tokens { + assembly: Assembly, + view: View, + dns: Option, + filesystem: Filesystem, + table: MappingTable, + identity: (u32, u32), + create_account: bool, + control: PathBuf, +} + +/// OpenVMM's one filesystem device: a managed export or the CLI's live shares. +#[derive(Debug)] +enum Filesystem { + None, + Export(HostExport), + Shared { + tag: String, + target: PathBuf, + access: Access, + children: Vec, + }, +} + +impl Tokens { + fn parse(command_line: &[u8]) -> Result { + let layout = RootLayout::parse(command_line) + .map_err(protocol_error)? + .ok_or_else(|| Error::unsupported("a layered cold boot needs root tokens"))?; + let view = protocol::view::parse(command_line) + .map_err(protocol_error)? + .ok_or_else(|| Error::unsupported("a layered cold boot needs view tokens"))?; + let table = MappingTable::announced(command_line).map_err(protocol_error)?; + let filesystem = filesystem(command_line)?; + if table.expected() != 0 && !matches!(filesystem, Filesystem::Export(_)) { + return Err(Error::unsupported("nvx_maps needs the host export")); + } + let identity = parameter(command_line, b"nvx_identity")? + .ok_or_else(|| Error::unsupported("a layered cold boot needs nvx_identity=UID:GID"))?; + let ids: Vec<_> = identity.split(|&byte| byte == b':').collect(); + let [uid, gid] = ids[..] else { + return Err(Error::unsupported("nvx_identity must be UID:GID")); + }; + let id = |bytes| { + super::parse_decimal(bytes) + .filter(|&value| value != 0 && value < u64::from(u32::MAX)) + .map(|value| value as u32) + .ok_or_else(|| Error::unsupported("nvx_identity needs nonzero decimal IDs")) + }; + let create_account = match parameter(command_line, b"nvx_workload_account")? { + None => false, + Some(b"create") => true, + Some(_) => return Err(Error::unsupported("nvx_workload_account must be create")), + }; + let control = parameter(command_line, b"nvx_control_tty")? + .ok_or_else(|| Error::unsupported("a layered cold boot needs nvx_control_tty"))?; + if control.is_empty() || !control.iter().all(u8::is_ascii_alphanumeric) { + return Err(Error::unsupported( + "nvx_control_tty must name a device in /dev", + )); + } + Ok(Self { + assembly: assembly(layout), + view, + dns: cmdline::dns_server(command_line).map_err(protocol_error)?, + filesystem, + table, + identity: (id(uid)?, id(gid)?), + create_account, + control: Path::new("/dev").join(OsStr::from_bytes(control)), + }) + } +} + +fn protocol_error(error: ProtocolError) -> Error { + Error::unsupported(error.to_string()).with_source(error) +} + +/// Reads one CLI-owned parameter without accepting a bare name or a duplicate. +fn parameter<'a>(command_line: &'a [u8], name: &[u8]) -> Result> { + let mut value = None; + for token in cmdline::init_tokens(command_line).filter(|token| token.name == name) { + if value.is_some() || token.value.is_none() { + return Err(Error::unsupported(format!( + "{} is repeated or has no value", + name.escape_ascii() + ))); + } + value = token.value; + } + Ok(value) +} + +fn assembly(layout: RootLayout) -> Assembly { + let image = match layout.image.format { + ImageFormat::Erofs => root::Image::Erofs { + uuid: layout.image.identity, + }, + ImageFormat::Gpt => root::Image::Gpt { + expected: layout + .image + .identity + .map_or_else(Expected::new, |guid| Expected::new().disk_guid(guid)), + first_partition_bound: usize::try_from( + layout.gpt_first_partition_bound.unwrap_or(u64::MAX), + ) + .unwrap_or(usize::MAX), + }, + }; + let upper = match layout.upper { + RootUpper::Ram => root::Upper::Tmpfs { + max_memory_percent: layout.max_memory_percent, + }, + RootUpper::Scratch => root::Upper::Scratch { + device: Device { + mmio: SCRATCH_MMIO_BASE, + sectors: None, + }, + journal: match layout.journal.unwrap_or(activation::Journal::Allowed) { + activation::Journal::Allowed => root::Journal::Allowed, + activation::Journal::Forbidden => root::Journal::Forbidden, + }, + }, + }; + Assembly { + runtime: PathBuf::from("/run/nvx"), + distro: Device { + mmio: DISTRO_MMIO_BASE, + sectors: None, + }, + image, + upper, + overlay: match layout + .overlay + .unwrap_or(activation::OverlayMode::TrustedLowers) + { + activation::OverlayMode::TrustedLowers => root::OverlayMode::TrustedLowers, + activation::OverlayMode::UntrustedLowers => root::OverlayMode::UntrustedLowers, + }, + timeout: Duration::from_secs(30), + } +} + +fn filesystem(command_line: &[u8]) -> Result { + let directory = parameter(command_line, b"virtfs_dir")?; + let tag = parameter(command_line, b"virtfs_tag")?; + let mode = parameter(command_line, b"virtfs_mode")?; + let aggregate = parameter(command_line, b"virtfs_aggregate")?; + let children = cmdline::init_tokens(command_line) + .filter(|token| token.name == b"nvx_share") + .map(|Token { value, .. }| { + value + .ok_or_else(|| Error::unsupported("nvx_share has no value")) + .and_then(share_child) + }) + .collect::>>()?; + if directory == Some(EXPORT_DIRECTORY.as_bytes()) && aggregate.is_some() { + return HostExport::parse(command_line) + .map_err(protocol_error) + .map(|export| Filesystem::Export(export.expect("the export names its directory"))); + } + let (Some(directory), Some(tag), Some(mode)) = (directory, tag, mode) else { + if directory.is_none() + && tag.is_none() + && mode.is_none() + && aggregate.is_none() + && children.is_empty() + { + return Ok(Filesystem::None); + } + return Err(Error::unsupported("the live share bootstrap is incomplete")); + }; + let tag = std::str::from_utf8(tag).map_err(|error| { + Error::unsupported("the live share tag is not ASCII").with_source(error) + })?; + if tag.is_empty() + || !tag + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(Error::unsupported("the live share tag is invalid")); + } + let target = PathBuf::from(OsStr::from_bytes(directory)); + match aggregate { + Some(b"1") if directory == b"/run/nvx/shares" && !children.is_empty() => {} + None if children.is_empty() => {} + _ => return Err(Error::unsupported("the live share aggregate is invalid")), + } + Ok(Filesystem::Shared { + tag: tag.to_owned(), + target, + access: access(mode)?, + children, + }) +} + +fn access(mode: &[u8]) -> Result { + match mode { + b"ro" => Ok(Access::ReadOnly), + b"rw" => Ok(Access::ReadWrite), + _ => Err(Error::unsupported("a live share mode must be ro or rw")), + } +} + +fn share_child(value: &[u8]) -> Result { + let fields: Vec<_> = value.split(|&byte| byte == b',').collect(); + let (name, target, mode, kind) = match fields[..] { + [name, target, mode] => (name, target, mode, ShareKind::Directory), + [name, target, mode, b"file"] => (name, target, mode, ShareKind::File), + _ => { + return Err(Error::unsupported( + "nvx_share must be CHILD,TARGET,MODE[,file]", + )); + } + }; + let name = std::str::from_utf8(name) + .map_err(|error| Error::unsupported("the share child is not ASCII").with_source(error))?; + Ok(ShareChild { + name: name.to_owned(), + target: PathBuf::from(OsStr::from_bytes(target)), + access: access(mode)?, + kind, + }) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/guest/init/boot/account.rs b/nvx-rs/src/guest/init/boot/account.rs new file mode 100644 index 000000000..e55cb0453 --- /dev/null +++ b/nvx-rs/src/guest/init/boot/account.rs @@ -0,0 +1,120 @@ +//! Resolve the shared root's account once, before any workload can run. + +use std::ffi::{CStr, CString}; +use std::path::Path; + +use crate::guest::account::{self, AccountError, Database}; +use crate::guest::sys::{self, Errno, FileDescriptor, Mode, OpenFlags}; +use crate::{Error, Result}; + +pub(super) fn resolve( + path: &Path, + (uid, gid): (u32, u32), + create: bool, +) -> Result<(CString, CString)> { + let root = sys::open( + &sys::path_c_string(path).map_err(failed)?, + OpenFlags::O_PATH | OpenFlags::O_DIRECTORY | OpenFlags::O_NOFOLLOW | OpenFlags::O_CLOEXEC, + Mode::empty(), + ) + .map_err(failed)?; + if create { + drop(account::read_database(&root, Database::Passwd).map_err(database_error)?); + let mut group = account::read_database(&root, Database::Group).map_err(database_error)?; + if !account::has_group(&group, gid) { + newline(&mut group); + group.extend_from_slice(format!("nvx:x:{gid}:\n").as_bytes()); + write(&root, c"group", &group).map_err(failed)?; + } + } + match account::existing_account(&root, uid, gid) { + Ok(account) => match home(&root, account.home()) { + Ok(()) => return strings(&account), + Err(error) + if create && matches!(error, Errno::ENOENT | Errno::ENOTDIR | Errno::ELOOP) => {} + Err(error) => return Err(failed(error)), + }, + Err( + error @ (AccountError::Read(..) | AccountError::NotFile(_) | AccountError::TooLarge(_)), + ) => { + return Err(Error::io(error.to_string()).with_source(error)); + } + Err(error) if !create => { + return Err(Error::unsupported(error.to_string()).with_source(error)); + } + Err( + AccountError::Missing + | AccountError::Duplicate + | AccountError::Malformed + | AccountError::PrimaryGroup(_) + | AccountError::Group + | AccountError::Home, + ) => {} + } + let passwd = account::read_database(&root, Database::Passwd).map_err(database_error)?; + let mut kept = Vec::new(); + for line in passwd.split_inclusive(|&byte| byte == b'\n') { + if !account::entries(line).any(|(id, _)| id == u64::from(uid)) { + kept.extend_from_slice(line); + } + } + newline(&mut kept); + kept.extend_from_slice( + format!("nvx:x:{uid}:{gid}:NVX workload:/tmp:/sbin/nologin\n").as_bytes(), + ); + let mode = Mode::from_bits_retain(0o1777); + match sys::create_directory_at(&root, c"tmp", mode) { + Ok(()) => sys::open_beneath( + &root, + c"tmp", + OpenFlags::O_RDONLY | OpenFlags::O_DIRECTORY, + Mode::empty(), + false, + ) + .and_then(|directory| directory.set_mode(mode)) + .map_err(failed)?, + Err(Errno::EEXIST) => home(&root, b"/tmp").map_err(failed)?, + Err(error) => return Err(failed(error)), + } + write(&root, c"passwd", &kept).map_err(failed)?; + strings(&account::existing_account(&root, uid, gid).map_err(database_error)?) +} + +fn home(root: &FileDescriptor, home: &[u8]) -> std::result::Result<(), Errno> { + root.open_directory_in_root(&sys::c_string(home)?).map(drop) +} + +fn strings(account: &account::Account) -> Result<(CString, CString)> { + Ok(( + CString::new(account.name()).map_err(failed)?, + CString::new(account.home()).map_err(failed)?, + )) +} + +fn newline(bytes: &mut Vec) { + if !bytes.is_empty() && !bytes.ends_with(b"\n") { + bytes.push(b'\n'); + } +} + +fn write(root: &FileDescriptor, name: &CStr, bytes: &[u8]) -> std::result::Result<(), Errno> { + if bytes.len() as u64 > account::MAX_ACCOUNT_DATABASE_BYTES { + return Err(Errno::EFBIG); + } + sys::open_beneath( + root, + c"etc", + OpenFlags::O_RDONLY | OpenFlags::O_DIRECTORY, + Mode::empty(), + false, + )? + .replace_file(name, bytes) +} + +fn database_error(error: AccountError) -> Error { + Error::io(error.to_string()).with_source(error) +} + +fn failed(error: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::io(format!("failed to prepare the workload account: {error}")).with_source(error) +} diff --git a/nvx-rs/src/guest/init/boot/tests.rs b/nvx-rs/src/guest/init/boot/tests.rs new file mode 100644 index 000000000..8ad287155 --- /dev/null +++ b/nvx-rs/src/guest/init/boot/tests.rs @@ -0,0 +1,402 @@ +use super::*; +use crate::ErrorKind; +use crate::activation::MachineIdMode; +use crate::guest::{account as accounts, sys}; + +const ROOT: &str = "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs"; +const VIEW: &str = "nvx_hostname=626f78 nvx_machine_id=generated"; +const CLI: &str = "nvx_identity=1000:1001 nvx_control_tty=hvc2"; +const EXPORT: &str = + "virtfs_dir=/run/nvx/hostfs/root virtfs_tag=hostfs virtfs_mode=rw virtfs_aggregate=1"; + +fn tokens(extra: &str) -> Tokens { + Tokens::parse(format!("{ROOT} {VIEW} {CLI} {extra}").as_bytes()).unwrap() +} + +#[test] +fn cold_boot_reads_the_actual_root_view_export_maps_and_cli_contracts() { + let tokens = tokens(&format!( + "nvx_ram_upper_percent=73 nvx_overlay_mode=untrusted-lowers \ + nvx_workload_account=create nvx_maps=2 virtnet_dns=10.0.0.1 {EXPORT}" + )); + assert_eq!( + tokens.assembly.upper, + root::Upper::Tmpfs { + max_memory_percent: Some(73) + } + ); + assert_eq!(tokens.assembly.overlay, root::OverlayMode::UntrustedLowers); + assert_eq!(tokens.assembly.distro.mmio, DISTRO_MMIO_BASE); + assert_eq!(tokens.view.hostname, b"box"); + assert_eq!(tokens.view.machine_id, MachineIdMode::Generated); + assert_eq!(tokens.dns, Some("10.0.0.1".parse().unwrap())); + assert_eq!(tokens.table.expected(), 2); + assert!(!tokens.table.is_complete()); + assert_eq!(tokens.identity, (1000, 1001)); + assert!(tokens.create_account); + assert_eq!(tokens.control, Path::new("/dev/hvc2")); + assert!( + matches!(tokens.filesystem, Filesystem::Export(HostExport { ref tag, .. }) if tag == "hostfs") + ); +} + +#[test] +fn gpt_scratch_settings_and_decoded_non_utf8_hostname_reach_the_assembly() { + let command = format!( + "nvx_layer=distro,0xd0003000,gpt:01234567-89ab-cdef-0123-456789abcdef \ + nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=forbidden \ + nvx_first_partition_bound=4096 nvx_overlay_mode=trusted-lowers \ + nvx_hostname=ff2e41 nvx_machine_id=image {CLI}" + ); + let tokens = Tokens::parse(command.as_bytes()).unwrap(); + assert_eq!(tokens.view.hostname, b"\xff.A"); + assert_eq!(tokens.view.machine_id, MachineIdMode::Image); + assert_eq!( + tokens.assembly.upper, + root::Upper::Scratch { + device: Device { + mmio: SCRATCH_MMIO_BASE, + sectors: None + }, + journal: root::Journal::Forbidden, + } + ); + match tokens.assembly.image { + root::Image::Gpt { + expected, + first_partition_bound, + } => { + assert_eq!(first_partition_bound, 4096); + assert_eq!( + expected, + Expected::new().disk_guid("01234567-89ab-cdef-0123-456789abcdef".parse().unwrap()) + ); + } + other => panic!("not the requested GPT image: {other:?}"), + } +} + +#[test] +fn a_cold_boot_needs_a_complete_layered_contract_and_unambiguous_cli_defaults() { + for command in [ + format!("{VIEW} {CLI}"), + format!("{ROOT} {CLI}"), + format!("{ROOT} {VIEW} nvx_control_tty=hvc2"), + format!("{ROOT} {VIEW} nvx_identity=1000:1001"), + format!("{ROOT} {VIEW} {CLI} nvx_maps=1"), + format!("{ROOT} {VIEW} {CLI} nvx_identity=1000:1001"), + format!("{ROOT} {VIEW} {CLI} nvx_identity"), + format!("{ROOT} {VIEW} {CLI} nvx_control_tty"), + format!("{ROOT} {VIEW} {CLI} nvx_workload_account=create nvx_workload_account=create"), + format!("{ROOT} {VIEW} nvx_control_tty=../hvc2 nvx_identity=1000:1001"), + format!("{ROOT} {VIEW} nvx_control_tty=hvc2 nvx_identity=0:1001"), + format!("{ROOT} {VIEW} nvx_control_tty=hvc2 nvx_identity=1000:4294967295"), + format!("{ROOT} {VIEW} nvx_control_tty=hvc2 nvx_identity=1000:1001:1002"), + format!("{ROOT} {VIEW} {CLI} nvx_ram_upper_percent=91"), + ] { + let error = Tokens::parse(command.as_bytes()).err().expect(&command); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{command}: {error}"); + } +} + +#[test] +fn live_shares_keep_their_actual_boot_forms_and_never_stand_in_for_an_export() { + for target in ["/run/app", EXPORT_DIRECTORY] { + for (mode, access) in [("ro", Access::ReadOnly), ("rw", Access::ReadWrite)] { + let form = format!("virtfs_dir={target} virtfs_tag=workspace virtfs_mode={mode}"); + let one = tokens(&form); + assert_eq!(one.assembly.overlay, root::OverlayMode::TrustedLowers); + assert!(matches!( + one.filesystem, + Filesystem::Shared { target: ref actual, access: actual_access, ref children, .. } + if actual == Path::new(target) && actual_access == access && children.is_empty() + )); + assert!( + Tokens::parse(format!("{ROOT} {VIEW} {CLI} {form} nvx_maps=1").as_bytes()).is_err() + ); + } + } + let aggregate = tokens( + "virtfs_dir=/run/nvx/shares virtfs_tag=workspace virtfs_mode=rw virtfs_aggregate=1 \ + nvx_share=0,/run/app,ro nvx_share=1,/etc/app.conf,rw,file", + ); + match aggregate.filesystem { + Filesystem::Shared { children, .. } => { + assert_eq!(children.len(), 2); + assert_eq!(children[0].target, Path::new("/run/app")); + assert_eq!(children[0].kind, ShareKind::Directory); + assert_eq!(children[1].target, Path::new("/etc/app.conf")); + assert_eq!(children[1].kind, ShareKind::File); + } + other => panic!("not the requested shares: {other:?}"), + } + for extra in [ + "virtfs_tag=workspace", + "virtfs_dir=/run/app virtfs_tag=workspace virtfs_mode=ro nvx_maps=1", + "nvx_share", + "nvx_share=0,/run/app,ro", + "virtfs_dir=/run/app virtfs_tag=workspace virtfs_mode=ro virtfs_aggregate=1", + "virtfs_dir=/run/nvx/shares virtfs_tag=workspace virtfs_mode=rw virtfs_aggregate=2", + "virtfs_dir=/run/nvx/hostfs/root virtfs_tag=workspace virtfs_mode=ro virtfs_aggregate=2", + "virtfs_dir=/run/nvx/hostfs/root virtfs_tag=workspace virtfs_aggregate=1", + ] { + assert!( + Tokens::parse(format!("{ROOT} {VIEW} {CLI} {extra}").as_bytes()).is_err(), + "{extra}" + ); + } +} + +fn account_root(passwd: &[u8], group: &[u8]) -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + std::fs::create_dir(directory.path().join("etc")).unwrap(); + std::fs::create_dir(directory.path().join("home")).unwrap(); + std::fs::write(directory.path().join("etc/passwd"), passwd).unwrap(); + std::fs::write(directory.path().join("etc/group"), group).unwrap(); + directory +} + +#[test] +fn an_existing_account_keeps_its_byte_defaults_without_rewriting_the_databases() { + for home in ["/home", "/home/", "/home///", "/", "///"] { + let passwd = [ + b"\xffuser:x:1000:1001::".as_slice(), + home.as_bytes(), + b":/bin/sh\n".as_slice(), + ] + .concat(); + let group = b"group:x:1001:\n"; + let root = account_root(&passwd, group); + for create in [false, true] { + let (user, resolved) = account::resolve(root.path(), (1000, 1001), create).unwrap(); + assert_eq!(user.as_bytes(), b"\xffuser"); + assert_eq!(resolved.as_bytes(), home.as_bytes()); + assert_eq!( + std::fs::read(root.path().join("etc/passwd")).unwrap(), + passwd + ); + assert_eq!(std::fs::read(root.path().join("etc/group")).unwrap(), group); + } + } +} + +#[test] +fn a_missing_group_is_repaired_without_replacing_a_usable_account() { + let passwd = b"\xffuser:x:1000:1001::/home:/bin/sh\n"; + let root = account_root(passwd, b"root:x:0:"); + assert!(account::resolve(root.path(), (1000, 1001), false).is_err()); + let (user, home) = account::resolve(root.path(), (1000, 1001), true).unwrap(); + assert_eq!(user.as_bytes(), b"\xffuser"); + assert_eq!(home.as_bytes(), b"/home"); + assert_eq!( + std::fs::read(root.path().join("etc/passwd")).unwrap(), + passwd + ); + assert_eq!( + std::fs::read(root.path().join("etc/group")).unwrap(), + b"root:x:0:\nnvx:x:1001:\n" + ); +} + +#[test] +fn home_ancestors_resolve_inside_the_real_root_but_the_final_component_is_no_link() { + for (target, home) in [(".", "/via/home"), ("/", "/via/home")] { + let passwd = format!("person:x:1000:1001::{home}:/bin/sh\n"); + let root = account_root(passwd.as_bytes(), b"people:x:1001:\n"); + std::os::unix::fs::symlink(target, root.path().join("via")).unwrap(); + for create in [false, true] { + let (user, resolved) = account::resolve(root.path(), (1000, 1001), create).unwrap(); + assert_eq!(user.as_bytes(), b"person"); + assert_eq!(resolved.as_bytes(), home.as_bytes()); + } + assert_eq!( + std::fs::read(root.path().join("etc/passwd")).unwrap(), + passwd.as_bytes() + ); + } + for home in ["/final", "/final/", "/final///"] { + let passwd = format!("person:x:1000:1001::{home}:/bin/sh\n"); + let group = b"people:x:1001:\n"; + let root = account_root(passwd.as_bytes(), group); + std::os::unix::fs::symlink("home", root.path().join("final")).unwrap(); + assert!(account::resolve(root.path(), (1000, 1001), false).is_err()); + assert_eq!( + std::fs::read(root.path().join("etc/passwd")).unwrap(), + passwd.as_bytes() + ); + let (user, resolved) = account::resolve(root.path(), (1000, 1001), true).unwrap(); + assert_eq!( + (user.as_bytes(), resolved.as_bytes()), + (b"nvx".as_slice(), b"/tmp".as_slice()) + ); + assert_eq!(std::fs::read(root.path().join("etc/group")).unwrap(), group); + } + + let outside = tempfile::tempdir().unwrap(); + std::fs::create_dir(outside.path().join("home")).unwrap(); + let root = account_root( + b"person:x:1000:1001::/outside/home:/bin/sh\n", + b"people:x:1001:\n", + ); + std::os::unix::fs::symlink(outside.path(), root.path().join("outside")).unwrap(); + assert!(account::resolve(root.path(), (1000, 1001), false).is_err()); +} + +#[test] +fn account_creation_replaces_only_the_requested_unusable_identity_once() { + let root = account_root( + b"root:x:0:0::/home:/bin/sh\n# keep:x:1000:0::/:/bin/sh\n\ + old:x:01000:2::/missing:/bin/sh\nbroken:x:1000\n\ + other:x:1000:3::/home:/bin/sh", + b"root:x:0:", + ); + assert!(account::resolve(root.path(), (1000, 1001), false).is_err()); + let (user, home) = account::resolve(root.path(), (1000, 1001), true).unwrap(); + assert_eq!(user.as_bytes(), b"nvx"); + assert_eq!(home.as_bytes(), b"/tmp"); + let passwd = std::fs::read(root.path().join("etc/passwd")).unwrap(); + let group = std::fs::read(root.path().join("etc/group")).unwrap(); + assert_eq!( + passwd, + b"root:x:0:0::/home:/bin/sh\n# keep:x:1000:0::/:/bin/sh\n\ + nvx:x:1000:1001:NVX workload:/tmp:/sbin/nologin\n" + ); + assert_eq!(group, b"root:x:0:\nnvx:x:1001:\n"); + account::resolve(root.path(), (1000, 1001), true).unwrap(); + assert_eq!( + std::fs::read(root.path().join("etc/passwd")).unwrap(), + passwd + ); + assert_eq!(std::fs::read(root.path().join("etc/group")).unwrap(), group); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(root.path().join("tmp")) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o1777 + ); +} + +#[test] +fn account_creation_never_replaces_a_link_or_an_unreadable_database() { + let root = account_root(b"root:x:0:0::/home:/bin/sh\n", b"root:x:0:\n"); + let passwd = root.path().join("etc/passwd"); + let outside = root.path().join("outside"); + std::fs::rename(&passwd, &outside).unwrap(); + std::os::unix::fs::symlink(&outside, &passwd).unwrap(); + assert!(account::resolve(root.path(), (1000, 1001), true).is_err()); + assert_eq!( + std::fs::read(&outside).unwrap(), + b"root:x:0:0::/home:/bin/sh\n" + ); + std::fs::remove_file(&passwd).unwrap(); + std::fs::write( + &passwd, + vec![b'x'; accounts::MAX_ACCOUNT_DATABASE_BYTES as usize + 1], + ) + .unwrap(); + assert!(account::resolve(root.path(), (1000, 1001), true).is_err()); + assert_eq!( + std::fs::metadata(passwd).unwrap().len(), + accounts::MAX_ACCOUNT_DATABASE_BYTES + 1 + ); +} + +/// These tests run only in a dedicated microVM with the candidate's actual root device and +/// filesystem export. They call the production boot sequence, not a simulated root or agent. +fn native_boot() -> Layered { + assert_eq!(std::env::var("NVX_LAYERED_BOOT_TEST").unwrap(), "1"); + assert!(nix::unistd::geteuid().is_root()); + let command_line = std::fs::read("/proc/cmdline").unwrap(); + let expected = Tokens::parse(&command_line).unwrap().view; + sys::enter_private_mount_namespace().unwrap(); + nix::sched::unshare(nix::sched::CloneFlags::CLONE_NEWUTS).unwrap(); + let boot = cold_boot(&command_line).unwrap(); + assert_eq!(boot.command_line, command_line); + assert_eq!(std::env::current_dir().unwrap(), Path::new("/")); + assert!( + !boot.root.path().exists(), + "the old runtime path must be hidden" + ); + let hostname = std::fs::read("/proc/sys/kernel/hostname").unwrap(); + assert_eq!(hostname.strip_suffix(b"\n").unwrap(), expected.hostname); + assert_eq!(boot.user.as_bytes(), b"nvx"); + assert_eq!(boot.home.as_bytes(), b"/tmp"); + let tty = nix::sys::termios::tcgetattr(boot.control.borrowed()).unwrap(); + assert!( + !tty.local_flags.intersects( + nix::sys::termios::LocalFlags::ECHO | nix::sys::termios::LocalFlags::ICANON + ) + ); + assert!(!std::fs::read("/run/nvx/time/state").unwrap().is_empty()); + assert_eq!(std::fs::read("/etc/machine-id").unwrap().len(), 33); + boot +} + +#[test] +#[ignore = "needs a dedicated NVX microVM with a real layered image and two announced mappings"] +fn layered_cold_boot_maps_through_handles_opened_before_its_only_switch() { + let mut boot = native_boot(); + assert_eq!(boot.table.expected(), 2); + assert!(!boot.table.is_complete()); + assert!(!Path::new(EXPORT_DIRECTORY).exists()); + let maps = ["/workspace", "/run/task-state"].map(|target| protocol::HostMap { + source: "0".to_owned(), + target: target.to_owned(), + read_only: true, + }); + let parts = protocol::encode_maps_payloads(&maps, 40).unwrap(); + assert_eq!(parts.len(), 2); + let export = boot + .export + .as_ref() + .expect("the real mounted export is retained"); + assert!( + boot.table + .apply(&parts[1], |_| -> std::result::Result<(), (usize, ())> { + panic!("an out-of-order part must not touch the retained mapper") + }) + .is_err() + ); + for (index, part) in parts.iter().enumerate() { + boot.table + .apply(part, |batch| { + boot.mapper + .apply(export, batch) + .map_err(|error| (error.entry(), error)) + }) + .unwrap(); + assert_eq!(boot.table.is_complete(), index == 1); + } + for target in ["/workspace/data", "/run/task-state/data"] { + assert_eq!(std::fs::read(target).unwrap(), b"sl75b-real-export\n"); + } + assert!(boot.root.switch().is_err()); + drop(boot); + assert_eq!( + std::fs::read("/run/task-state/data").unwrap(), + b"sl75b-real-export\n" + ); +} + +#[test] +#[ignore = "needs a dedicated NVX microVM with a real layered image and live share beneath /run"] +fn layered_cold_boot_mounts_runtime_before_a_live_share_beneath_run() { + let mut boot = native_boot(); + let file = match filesystem(&boot.command_line).unwrap() { + Filesystem::Shared { + target, children, .. + } if children.is_empty() => target.join("data"), + other => panic!("not the requested single live share: {other:?}"), + }; + assert!(boot.export.is_none()); + assert!(boot.table.is_complete()); + assert_eq!(std::fs::read(&file).unwrap(), b"sl75b-real-export\n"); + assert!(boot.root.mount_runtime(None).is_err()); + assert!(boot.root.switch().is_err()); + assert_eq!(std::fs::read(&file).unwrap(), b"sl75b-real-export\n"); + drop(boot); + assert_eq!(std::fs::read(&file).unwrap(), b"sl75b-real-export\n"); +} diff --git a/nvx-rs/src/guest/init/tests.rs b/nvx-rs/src/guest/init/tests.rs index 466b1125b..5632fcea1 100644 --- a/nvx-rs/src/guest/init/tests.rs +++ b/nvx-rs/src/guest/init/tests.rs @@ -21,6 +21,25 @@ use crate::guest::sys::tests::{ }; use crate::guest::sys::{ForkResult, wait}; +/// Drives the unchanged helper/signal scripts through production preparation, collection and +/// shutdown; layered boot itself is exercised by the boot tests. +fn init(system: &mut impl System, arguments: &[CString]) { + let result = serve(system, arguments); + finish(system, result); +} + +fn serve(system: &mut impl System, arguments: &[CString]) -> Result<(), String> { + let helpers = prepare(system, arguments)?; + let mut terminating = false; + loop { + collect(system, helpers, |_, _| {})?; + if terminating { + return Ok(()); + } + terminating = system.wait_signal() == Some(Signal::SIGTERM); + } +} + /// One operation that PID 1 asked its system for. #[derive(Clone, Debug, PartialEq, Eq)] enum Call { diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 039d463fd..fdfe762a5 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -92,6 +92,7 @@ #![deny(unsafe_code)] #![deny(clippy::print_stdout, clippy::print_stderr)] +mod account; mod generation; mod parse; mod status; diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index 1c35b7e66..8c89b8630 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -913,7 +913,6 @@ impl Root { /// `nodev`. Each attachment is checked at its named target before it is recorded. A runtime /// mount whose attachment fails and which cannot be detached keeps its mount descriptor, /// so rollback and teardown can retry the detach without relying on its original path. - #[cfg_attr(not(test), expect(dead_code, reason = "nvx-init calls it (#1115)"))] pub(crate) fn mount_runtime(&mut self, time_status: Option<&Path>) -> Result<(), Error> { if let Some((runtime, _)) = &self.runtime_stray { return Err(Error::Occupied { @@ -1048,7 +1047,6 @@ impl Root { /// usable: a [`Mapper`] still maps from a [`SourceRoot`](super::mappings::SourceRoot) beneath /// a [`TargetRoot`] opened before it, as [`map`](Self::map) does if the root had mounted a /// live share or a mapping before it. - #[cfg_attr(not(test), expect(dead_code, reason = "nvx-init calls it (#1115)"))] pub(crate) fn switch(&mut self) -> Result<(), SwitchError> { if self.switched { return Err(SwitchError::Switched); diff --git a/nvx-rs/src/guest/sys/rooted.rs b/nvx-rs/src/guest/sys/rooted.rs index af6e29954..37323baf5 100644 --- a/nvx-rs/src/guest/sys/rooted.rs +++ b/nvx-rs/src/guest/sys/rooted.rs @@ -238,6 +238,93 @@ pub(crate) fn open_beneath( nix::fcntl::openat2(directory.borrowed(), path, how).map(FileDescriptor::from_owned) } +#[cfg(target_arch = "x86_64")] +impl FileDescriptor { + /// Opens a directory inside this root, following ordinary ancestor links but not a final + /// link or a magic link. Absolute links and `..` remain inside this root. + pub(crate) fn open_directory_in_root(&self, path: &CStr) -> Result { + let mut bytes = path.to_bytes(); + while bytes.len() > 1 && bytes.ends_with(b"/") { + bytes = &bytes[..bytes.len() - 1]; + } + let path = super::c_string(bytes)?; + let how = OpenHow::new() + .flags( + OpenFlags::O_PATH + | OpenFlags::O_DIRECTORY + | OpenFlags::O_NOFOLLOW + | OpenFlags::O_CLOEXEC, + ) + .resolve(ResolveFlag::RESOLVE_IN_ROOT | ResolveFlag::RESOLVE_NO_MAGICLINKS); + nix::fcntl::openat2(self.borrowed(), path.as_c_str(), how).map(Self::from_owned) + } + + /// Replaces one regular file of this directory atomically, retaining its owner and mode. + /// No path component or temporary file is resolved against the process's working directory. + pub(crate) fn replace_file(&self, name: &CStr, bytes: &[u8]) -> Result<(), Errno> { + use nix::sys::stat::SFlag; + use nix::unistd::{Gid, Uid}; + + if name.to_bytes().contains(&b'/') || matches!(name.to_bytes(), b"" | b"." | b"..") { + return Err(Errno::EINVAL); + } + let original = open_beneath( + self, + name, + OpenFlags::O_RDONLY | OpenFlags::O_NONBLOCK | OpenFlags::O_NOCTTY, + Mode::empty(), + false, + )?; + let status = nix::sys::stat::fstat(original.borrowed())?; + if SFlag::from_bits_truncate(status.st_mode) != SFlag::S_IFREG { + return Err(Errno::EINVAL); + } + let random = super::open( + c"/dev/urandom", + OpenFlags::O_RDONLY | OpenFlags::O_NOFOLLOW | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?; + let mut nonce = [0; 8]; + random.read_exact(&mut nonce)?; + let temporary_name = + super::c_string(format!(".nvx-{:016x}", u64::from_ne_bytes(nonce)).as_bytes())?; + let temporary = open_beneath( + self, + &temporary_name, + OpenFlags::O_WRONLY | OpenFlags::O_CREAT | OpenFlags::O_EXCL, + Mode::from_bits_truncate(0o600), + false, + )?; + let renamed = (|| { + temporary.write_all(bytes)?; + nix::unistd::fchown( + temporary.borrowed(), + Some(Uid::from_raw(status.st_uid)), + Some(Gid::from_raw(status.st_gid)), + )?; + temporary.set_mode(Mode::from_bits_truncate(status.st_mode))?; + temporary.sync()?; + temporary.close()?; + nix::fcntl::renameat( + self.borrowed(), + temporary_name.as_c_str(), + self.borrowed(), + name, + ) + })(); + if renamed.is_err() { + // The original error wins; unlinking only our exclusive sibling is best effort. + let _ = nix::unistd::unlinkat( + self.borrowed(), + temporary_name.as_c_str(), + nix::unistd::UnlinkatFlags::NoRemoveDir, + ); + } + renamed?; + self.sync() + } +} + /// Returns the kind of the file that `descriptor` opened, its owner and mode, its mount's unique /// ID, and whether it is the mount's root, from `statx`. /// diff --git a/nvx-rs/src/guest/sys/rooted/tests.rs b/nvx-rs/src/guest/sys/rooted/tests.rs index a851633f4..6b0bd709b 100644 --- a/nvx-rs/src/guest/sys/rooted/tests.rs +++ b/nvx-rs/src/guest/sys/rooted/tests.rs @@ -145,6 +145,94 @@ fn status(descriptor: &FileDescriptor) -> Option { } } +#[test] +#[cfg(target_arch = "x86_64")] +fn atomic_replacements_keep_the_open_original_and_its_owner_and_mode() { + use std::io::Read; + use std::os::unix::fs::MetadataExt; + + with_thread_umask(0o077, || { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("database"); + std::fs::write(&path, b"old database\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o664)).unwrap(); + let before = std::fs::metadata(&path).unwrap(); + let mut original = std::fs::File::open(&path).unwrap(); + let directory = open( + &path_c_string(root.path()).unwrap(), + OpenFlags::O_RDONLY | OpenFlags::O_DIRECTORY | OpenFlags::O_CLOEXEC, + Mode::empty(), + ) + .unwrap(); + directory + .replace_file(c"database", b"new database\n") + .unwrap(); + let after = std::fs::metadata(&path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new database\n"); + let mut kept = Vec::new(); + original.read_to_end(&mut kept).unwrap(); + assert_eq!(kept, b"old database\n"); + assert_eq!( + (after.uid(), after.gid(), after.mode()), + (before.uid(), before.gid(), before.mode()) + ); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); + std::os::unix::fs::symlink("database", root.path().join("link")).unwrap(); + assert_eq!( + directory.replace_file(c"link", b"refused"), + Err(Errno::ELOOP) + ); + assert_eq!(std::fs::read(&path).unwrap(), b"new database\n"); + }); + let root = path_descriptor(std::path::Path::new("/")); + assert_eq!( + root.open_directory_in_root(c"/proc/self/root/tmp") + .map(drop), + Err(Errno::ELOOP) + ); +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn a_partial_replacement_write_keeps_the_old_file_and_removes_only_its_temporary() { + if crate::guest::sys::tests::isolate_process_test( + "NVX_ATOMIC_FILE_LIMIT_TEST", + "guest::sys::rooted::tests::a_partial_replacement_write_keeps_the_old_file_and_removes_only_its_temporary", + ) { + return; + } + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("database"); + std::fs::write(&path, b"old database\n").unwrap(); + let directory = open( + &path_c_string(root.path()).unwrap(), + OpenFlags::O_RDONLY | OpenFlags::O_DIRECTORY | OpenFlags::O_CLOEXEC, + Mode::empty(), + ) + .unwrap(); + let mut blocked = nix::sys::signal::SigSet::empty(); + blocked.add(nix::sys::signal::Signal::SIGXFSZ); + blocked.thread_block().unwrap(); + let mut previous = nix::libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: `previous` is a writable rlimit that getrlimit fills. + Errno::result(unsafe { nix::libc::getrlimit(nix::libc::RLIMIT_FSIZE, &mut previous) }).unwrap(); + let restricted = nix::libc::rlimit { + rlim_cur: 17, + rlim_max: previous.rlim_max, + }; + // SAFETY: the isolated test process owns this limit and both rlimit values outlive the calls. + Errno::result(unsafe { nix::libc::setrlimit(nix::libc::RLIMIT_FSIZE, &restricted) }).unwrap(); + let replaced = directory.replace_file(c"database", &[b'x'; 100]); + // SAFETY: the original rlimit remains valid; restore it before the test harness writes logs. + Errno::result(unsafe { nix::libc::setrlimit(nix::libc::RLIMIT_FSIZE, &previous) }).unwrap(); + assert_eq!(replaced, Err(Errno::EFBIG)); + assert_eq!(std::fs::read(&path).unwrap(), b"old database\n"); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); +} + #[test] fn paths_resolve_only_beneath_their_directory_and_through_no_link() { let temporary = tempfile::tempdir().unwrap(); diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 881fb569e..ca8ea1991 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -6,8 +6,8 @@ "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, "nvx-rs/src/guest/init.rs": 8, + "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, - "nvx-rs/src/guest/root.rs": 2, "nvx-rs/src/guest/sys/entry.rs": 3, "nvx-rs/src/guest/sys/spawn.rs": 7, "nvx-rs/src/guest/time/system.rs": 3, diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 674fe88ea..f0939ffa3 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -980,25 +980,65 @@ def test_contained_process_timeout_kills_descendants(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) child_pid_path = root / "child.pid" - result = run_bounded_process( - [ - sys.executable, - "-c", - ( - "import pathlib,subprocess,sys,time; " - "child=subprocess.Popen([sys.executable,'-c'," - "'import time; time.sleep(60)']); " - "pathlib.Path(sys.argv[1]).write_text(str(child.pid)); " - "time.sleep(60)" - ), - str(child_pid_path), - ], - cwd=Path.cwd(), - output_dir=root / "process", - timeout=1.0, - environment=os.environ, - contained_by_parent=True, - ) + attach = ProcessTreeContainment.attach + + def attach_ready( + containment: ProcessTreeContainment, + process: subprocess.Popen[bytes], + ) -> None: + attach(containment, process) + ready = False + try: + deadline = time.monotonic() + 5.0 + while not child_pid_path.exists(): + self.assertIsNone( + process.poll(), "fixture parent exited before readiness" + ) + self.assertLess( + time.monotonic(), + deadline, + "fixture child did not become ready", + ) + time.sleep(0.01) + self.assertIsNone( + process.poll(), "fixture parent exited before readiness" + ) + child_pid = int(child_pid_path.read_text(encoding="utf-8")) + self.assertGreater(child_pid, 0) + self.assertTrue(_process_running(child_pid)) + ready = True + finally: + if not ready: + try: + containment.close(process) + finally: + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + + with patch.object(ProcessTreeContainment, "attach", attach_ready): + result = run_bounded_process( + [ + sys.executable, + "-c", + ( + "import subprocess,sys,time; " + "child=subprocess.Popen([sys.executable,'-c'," + "'import os,pathlib,sys,time; " + "path=pathlib.Path(sys.argv[1]); " + 'path.with_suffix(".tmp").write_text(str(os.getpid())); ' + 'path.with_suffix(".tmp").replace(path); ' + "time.sleep(60)',sys.argv[1]]); " + "time.sleep(60)" + ), + str(child_pid_path), + ], + cwd=Path.cwd(), + output_dir=root / "process", + timeout=1.0, + environment=os.environ, + contained_by_parent=True, + ) child_pid = int(child_pid_path.read_text(encoding="utf-8")) deadline = time.monotonic() + 2.0 while _process_running(child_pid) and time.monotonic() < deadline: