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: