From 85bb89aceedba4a587867e30d1fc7910d29ee647 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 25 Oct 2026 19:37:02 -0700 Subject: [PATCH 01/17] refactor: split up dmsetup into type-safe utility --- native/suidhelper/Cargo.toml | 4 + native/suidhelper/src/lib.rs | 3 +- native/suidhelper/src/linux/dm/device.rs | 35 ++++ native/suidhelper/src/linux/dm/mod.rs | 128 +++++++++++++ native/suidhelper/src/linux/dm/table.rs | 59 ++++++ native/suidhelper/src/linux/dm/thin.rs | 180 ++++++++++++++++++ native/suidhelper/src/linux/mod.rs | 3 + .../suidhelper/src/tools/dmsetup/message.rs | 56 +++--- native/suidhelper/src/tools/dmsetup/mod.rs | 125 +++++------- .../suidhelper/src/tools/dmsetup/snapshot.rs | 50 ----- native/suidhelper/src/tools/dmsetup/table.rs | 87 ++++++--- native/suidhelper/src/tools/dmsetup/thin.rs | 38 ---- .../suidhelper/src/tools/dmsetup/thin_pool.rs | 35 ---- native/suidhelper/src/tools/mod.rs | 4 +- native/suidhelper/src/util/jailer.rs | 18 +- native/suidhelper/src/util/safe_dev.rs | 6 + native/suidhelper/tests/e2e/argv.rs | 9 +- native/suidhelper/tests/linux/dm.rs | 152 +++++++++++++++ .../suidhelper/tests/tools/dmsetup_parsers.rs | 71 ++++--- 19 files changed, 789 insertions(+), 274 deletions(-) create mode 100644 native/suidhelper/src/linux/dm/device.rs create mode 100644 native/suidhelper/src/linux/dm/mod.rs create mode 100644 native/suidhelper/src/linux/dm/table.rs create mode 100644 native/suidhelper/src/linux/dm/thin.rs create mode 100644 native/suidhelper/src/linux/mod.rs delete mode 100644 native/suidhelper/src/tools/dmsetup/snapshot.rs delete mode 100644 native/suidhelper/src/tools/dmsetup/thin.rs delete mode 100644 native/suidhelper/src/tools/dmsetup/thin_pool.rs create mode 100644 native/suidhelper/tests/linux/dm.rs diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index d162a0a6..14632eb4 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -48,6 +48,10 @@ path = "tests/util/safe_bin.rs" name = "tools_dmsetup_parsers" path = "tests/tools/dmsetup_parsers.rs" +[[test]] +name = "linux_dm" +path = "tests/linux/dm.rs" + [[test]] name = "tools_blockcopy" path = "tests/tools/blockcopy.rs" diff --git a/native/suidhelper/src/lib.rs b/native/suidhelper/src/lib.rs index 11f3be55..3f56f65f 100644 --- a/native/suidhelper/src/lib.rs +++ b/native/suidhelper/src/lib.rs @@ -9,9 +9,8 @@ pub mod checksum; pub mod config; -// The filesystem utilities are only tested/designed for UNIX. -#[cfg(unix)] pub mod fs; +pub mod linux; pub mod process; pub mod security_gate; pub mod tools; diff --git a/native/suidhelper/src/linux/dm/device.rs b/native/suidhelper/src/linux/dm/device.rs new file mode 100644 index 00000000..8ff231fe --- /dev/null +++ b/native/suidhelper/src/linux/dm/device.rs @@ -0,0 +1,35 @@ +use super::{Dmsetup, RemoveMode, RunError}; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct Device<'a> { + pub(super) dmsetup: &'a Dmsetup, + pub(super) name: String, +} + +impl<'a> Device<'a> { + pub fn name(&self) -> &str { + &self.name + } + + pub fn path(&self) -> PathBuf { + PathBuf::from(format!("/dev/mapper/{}", self.name)) + } + + pub fn suspend(&self) -> Result<(), RunError> { + self.dmsetup.run(["suspend", self.name.as_str()]).map(drop) + } + + pub fn resume(&self) -> Result<(), RunError> { + self.dmsetup.run(["resume", self.name.as_str()]).map(drop) + } + + pub fn remove(self, mode: RemoveMode) -> Result<(), RunError> { + let mut args = vec!["remove"]; + if mode == RemoveMode::Retry { + args.push("--retry"); + } + args.push(self.name.as_str()); + self.dmsetup.run(args).map(drop) + } +} diff --git a/native/suidhelper/src/linux/dm/mod.rs b/native/suidhelper/src/linux/dm/mod.rs new file mode 100644 index 00000000..c8a271be --- /dev/null +++ b/native/suidhelper/src/linux/dm/mod.rs @@ -0,0 +1,128 @@ +//! Utilities for operating on linux `dsetup`. + +mod device; +mod table; +mod thin; + +use crate::process::binary::RootSafeBin; +pub use device::Device; +use std::ffi::OsStr; +use std::io; +pub use table::{DmTable, Persistence, SnapshotTable, ThinPoolTable, ThinTable}; +pub use thin::{ + Active, Inactive, SnapshotError, Thin, ThinId, ThinIdOutOfRange, ThinPool, ThinPoolError, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RunError { + #[error("running dmsetup: {0}")] + Spawn(#[source] io::Error), + #[error("dmsetup failed: {0}")] + Failed(String), +} + +#[derive(Debug, Error)] +pub enum TargetsError { + #[error(transparent)] + Run(#[from] RunError), + #[error("unexpected `dmsetup targets` line: {0:?}")] + Malformed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + ReadOnly, + ReadWrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoveMode { + Once, + Retry, +} + +/// Type-safe interface to `dmsetup`. +#[derive(Debug)] +pub struct Dmsetup { + bin: RootSafeBin<"dmsetup">, +} + +impl Dmsetup { + pub fn new(bin: RootSafeBin<"dmsetup">) -> Self { + Self { bin } + } + + pub fn create( + &self, + name: &str, + table: &DmTable, + access: Access, + ) -> Result, RunError> { + let table = table.to_string(); + let mut args = vec!["create", name]; + if access == Access::ReadOnly { + args.push("--readonly"); + } + args.extend(["--table", table.as_str()]); + self.run(args)?; + Ok(self.device(name)) + } + + pub fn device(&self, name: &str) -> Device<'_> { + Device { + dmsetup: self, + name: name.to_string(), + } + } + + pub fn thin_pool(&self, name: &str) -> Result, ThinPoolError> { + ThinPool::open(self, name) + } + + pub fn targets(&self) -> Result, TargetsError> { + self.run(["targets"])? + .lines() + .filter(|line| !line.trim().is_empty()) + .map( + |line| match line.split_whitespace().collect::>().as_slice() { + [name, version] => Ok(Target { + name: name.to_string(), + version: version.to_string(), + }), + _ => Err(TargetsError::Malformed(line.to_string())), + }, + ) + .collect() + } + + pub fn list(&self) -> Result { + self.run(["ls"]) + } + + fn run(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let out = self + .bin + .command() + .args(args) + .env_clear() + .output() + .map_err(RunError::Spawn)?; + if !out.status.success() { + return Err(RunError::Failed( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } +} diff --git a/native/suidhelper/src/linux/dm/table.rs b/native/suidhelper/src/linux/dm/table.rs new file mode 100644 index 00000000..40d19887 --- /dev/null +++ b/native/suidhelper/src/linux/dm/table.rs @@ -0,0 +1,59 @@ +use super::ThinId; +use derive_more::Display; +use std::path::PathBuf; + +/// Dmsetup tables. See `man dmsetup` for more info, under the `--table` docs. +#[derive(Debug, Clone, Display)] +pub enum DmTable { + Snapshot(SnapshotTable), + ThinPool(ThinPoolTable), + Thin(ThinTable), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)] +pub enum Persistence { + #[display("P")] + Persistent, + #[display("N")] + Transient, +} + +/// `dm-snapshot` table. +#[derive(Debug, Clone, Display)] +#[display("0 {sectors} snapshot {} {} {persistence} {chunk}", origin.display(), cow.display())] +pub struct SnapshotTable { + pub sectors: u64, + pub origin: PathBuf, + pub cow: PathBuf, + pub persistence: Persistence, + pub chunk: u64, +} + +/// `dm-thin` pool table. +#[derive(Debug, Clone, Display)] +#[display( + "0 {sectors} thin-pool {} {} {block_sectors} {low_water}", + metadata.display(), + data.display() +)] +pub struct ThinPoolTable { + pub sectors: u64, + pub metadata: PathBuf, + pub data: PathBuf, + pub block_sectors: u64, + pub low_water: u64, +} + +/// `dm-thin` tables. +#[derive(Debug, Clone, Display)] +#[display( + "0 {sectors} thin {} {id}{}", + pool.display(), + origin.as_ref().map(|o| format!(" {}", o.display())).unwrap_or_default() +)] +pub struct ThinTable { + pub sectors: u64, + pub pool: PathBuf, + pub id: ThinId, + pub origin: Option, +} diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs new file mode 100644 index 00000000..bf42190e --- /dev/null +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -0,0 +1,180 @@ +use super::{Access, Device, DmTable, Dmsetup, RemoveMode, RunError, ThinTable}; +use std::fmt; +use std::path::PathBuf; +use thiserror::Error as ThisError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ThinId(u32); + +#[derive(Debug, ThisError)] +#[error("thin device id {0} exceeds the 24-bit limit")] +pub struct ThinIdOutOfRange(u32); + +impl ThinId { + pub const MAX: u32 = (1 << 24) - 1; + + pub fn new(id: u32) -> Result { + if id <= Self::MAX { + Ok(Self(id)) + } else { + Err(ThinIdOutOfRange(id)) + } + } +} + +impl fmt::Display for ThinId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, ThisError)] +pub enum ThinPoolError { + #[error(transparent)] + Run(#[from] RunError), + #[error("{name} is not a thin-pool device: {table:?}")] + NotAThinPool { name: String, table: String }, +} + +#[derive(Debug, ThisError)] +pub enum SnapshotError { + #[error("suspending origin: {0}")] + Suspend(#[source] RunError), + #[error("sending create_snap: {0}")] + CreateSnap(#[source] RunError), + #[error("resuming origin: {0}")] + Resume(#[source] RunError), +} + +#[derive(Debug, Clone)] +pub struct ThinPool<'a> { + device: Device<'a>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Inactive; + +#[derive(Debug)] +pub struct Active<'a>(Device<'a>); + +#[derive(Debug)] +pub struct Thin<'a, S> { + pool: ThinPool<'a>, + id: ThinId, + state: S, +} + +impl<'a> ThinPool<'a> { + pub(super) fn open(dmsetup: &'a Dmsetup, name: &str) -> Result { + let table = dmsetup.run(["table", name])?; + match table.lines().collect::>().as_slice() { + [line] if line.split_whitespace().nth(2) == Some("thin-pool") => Ok(Self { + device: dmsetup.device(name), + }), + _ => Err(ThinPoolError::NotAThinPool { + name: name.to_string(), + table, + }), + } + } + + pub fn device(&self) -> &Device<'a> { + &self.device + } + + pub fn create_thin(&self, id: ThinId) -> Result, RunError> { + self.message(&format!("create_thin {id}"))?; + Ok(Thin { + pool: self.clone(), + id, + state: Inactive, + }) + } + + pub fn create_snap(&self, id: ThinId, origin: ThinId) -> Result<(), RunError> { + self.message(&format!("create_snap {id} {origin}")) + } + + pub fn delete_thin(&self, id: ThinId) -> Result<(), RunError> { + self.message(&format!("delete {id}")) + } + + pub fn reserve_metadata_snap(&self) -> Result<(), RunError> { + self.message("reserve_metadata_snap") + } + + pub fn release_metadata_snap(&self) -> Result<(), RunError> { + self.message("release_metadata_snap") + } + + fn message(&self, message: &str) -> Result<(), RunError> { + self.device + .dmsetup + .run(["message", self.device.name(), "0", message]) + .map(drop) + } +} + +impl Thin<'_, S> { + pub fn id(&self) -> ThinId { + self.id + } +} + +impl<'a> Thin<'a, Inactive> { + pub fn activate( + self, + name: &str, + sectors: u64, + origin: Option, + ) -> Result>, RunError> { + let table = DmTable::Thin(ThinTable { + sectors, + pool: self.pool.device.path(), + id: self.id, + origin, + }); + let device = self + .pool + .device + .dmsetup + .create(name, &table, Access::ReadWrite)?; + Ok(Thin { + pool: self.pool, + id: self.id, + state: Active(device), + }) + } + + pub fn delete(self) -> Result<(), RunError> { + self.pool.delete_thin(self.id) + } +} + +impl<'a> Thin<'a, Active<'a>> { + pub fn device(&self) -> &Device<'a> { + &self.state.0 + } + + pub fn snapshot(&self, new_id: ThinId) -> Result, SnapshotError> { + let device = self.device(); + device.suspend().map_err(SnapshotError::Suspend)?; + let snapped = self.pool.create_snap(new_id, self.id); + device.resume().map_err(SnapshotError::Resume)?; + snapped.map_err(SnapshotError::CreateSnap)?; + Ok(Thin { + pool: self.pool.clone(), + id: new_id, + state: Inactive, + }) + } + + pub fn deactivate(self) -> Result, RunError> { + self.state.0.remove(RemoveMode::Retry)?; + Ok(Thin { + pool: self.pool, + id: self.id, + state: Inactive, + }) + } +} diff --git a/native/suidhelper/src/linux/mod.rs b/native/suidhelper/src/linux/mod.rs new file mode 100644 index 00000000..04b17d5b --- /dev/null +++ b/native/suidhelper/src/linux/mod.rs @@ -0,0 +1,3 @@ +//! Linux-specific utilities + +pub mod dm; diff --git a/native/suidhelper/src/tools/dmsetup/message.rs b/native/suidhelper/src/tools/dmsetup/message.rs index 62d2e7e0..691c211e 100644 --- a/native/suidhelper/src/tools/dmsetup/message.rs +++ b/native/suidhelper/src/tools/dmsetup/message.rs @@ -1,33 +1,41 @@ -use super::Error; -use derive_more::Display; -use std::str::FromStr; +use crate::linux::dm::{ThinId, ThinIdOutOfRange}; +use thiserror::Error as ThisError; /// A thin-pool message we permit: provision, snapshot, or drop a thin device by id, or pin/unpin the pool's metadata snapshot. -#[derive(Clone, Display)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThinMessage { - #[display("create_thin {_0}")] - CreateThin(u64), - #[display("create_snap {_0} {_1}")] - CreateSnap(u64, u64), - #[display("delete {_0}")] - Delete(u64), - #[display("reserve_metadata_snap")] + CreateThin(ThinId), + CreateSnap { id: ThinId, origin: ThinId }, + Delete(ThinId), ReserveMetadataSnap, - #[display("release_metadata_snap")] ReleaseMetadataSnap, } -impl FromStr for ThinMessage { - type Err = Error; - fn from_str(s: &str) -> Result { - let id = |t: &str| t.parse().map_err(|_| Error::BadTable(s.to_string())); - match s.split_whitespace().collect::>().as_slice() { - ["create_thin", i] => Ok(ThinMessage::CreateThin(id(i)?)), - ["create_snap", new, orig] => Ok(ThinMessage::CreateSnap(id(new)?, id(orig)?)), - ["delete", i] => Ok(ThinMessage::Delete(id(i)?)), - ["reserve_metadata_snap"] => Ok(ThinMessage::ReserveMetadataSnap), - ["release_metadata_snap"] => Ok(ThinMessage::ReleaseMetadataSnap), - _ => Err(Error::BadTable(s.to_string())), - } +#[derive(Debug, ThisError)] +pub enum MessageError { + #[error("unsupported thin-pool message: {0}")] + Malformed(String), + #[error(transparent)] + ThinId(#[from] ThinIdOutOfRange), +} + +pub fn parse_message(s: &str) -> Result { + let id = |t: &str| -> Result { + let raw = t + .parse() + .map_err(|_| MessageError::Malformed(s.to_string()))?; + Ok(ThinId::new(raw)?) + }; + + match s.split_whitespace().collect::>().as_slice() { + ["create_thin", i] => Ok(ThinMessage::CreateThin(id(i)?)), + ["create_snap", new, orig] => Ok(ThinMessage::CreateSnap { + id: id(new)?, + origin: id(orig)?, + }), + ["delete", i] => Ok(ThinMessage::Delete(id(i)?)), + ["reserve_metadata_snap"] => Ok(ThinMessage::ReserveMetadataSnap), + ["release_metadata_snap"] => Ok(ThinMessage::ReleaseMetadataSnap), + _ => Err(MessageError::Malformed(s.to_string())), } } diff --git a/native/suidhelper/src/tools/dmsetup/mod.rs b/native/suidhelper/src/tools/dmsetup/mod.rs index 31c7ebcd..cf43ecb1 100644 --- a/native/suidhelper/src/tools/dmsetup/mod.rs +++ b/native/suidhelper/src/tools/dmsetup/mod.rs @@ -1,32 +1,15 @@ mod message; -mod snapshot; mod table; -mod thin; -mod thin_pool; use super::IsTool; +use crate::linux::dm::{self, Access, DmTable, RemoveMode}; use crate::process::binary::RootSafeBin; -use crate::util::safe_dev::{self, DmName}; +use crate::util::safe_dev::DmName; use clap::{Args, Subcommand}; -pub use message::ThinMessage; +pub use message::{parse_message, MessageError, ThinMessage}; use serde::Serialize; -use std::io; use std::path::PathBuf; -use std::process::Output; -pub use table::DmTable; -use thiserror::Error as ThisError; - -#[derive(Debug, ThisError)] -pub enum Error { - #[error("dmsetup table must be a snapshot target: {0}")] - BadTable(String), - #[error(transparent)] - Device(#[from] safe_dev::Error), - #[error("running dmsetup: {0}")] - Spawn(#[source] io::Error), - #[error("dmsetup failed: {0}")] - Failed(String), -} +pub use table::{parse_table, TableError}; #[derive(Args)] pub struct DmsetupArgs { @@ -40,7 +23,7 @@ enum DmOp { name: DmName, #[arg(long)] readonly: bool, - #[arg(long)] + #[arg(long, value_parser = parse_table)] table: DmTable, }, Remove { @@ -50,7 +33,7 @@ enum DmOp { }, Message { name: DmName, - #[arg(long)] + #[arg(long, value_parser = parse_message)] message: ThinMessage, }, /// Suspend a dm device (flushes queued I/O; required before `create_snap` @@ -78,87 +61,81 @@ pub enum DmsetupOut { } pub struct Dmsetup { - bin: RootSafeBin<"dmsetup">, + dmsetup: dm::Dmsetup, op: DmOp, } impl Dmsetup { pub fn new(bin: RootSafeBin<"dmsetup">, args: DmsetupArgs) -> Self { - Self { bin, op: args.op } + Self { + dmsetup: dm::Dmsetup::new(bin), + op: args.op, + } } } impl IsTool for Dmsetup { type Args = DmsetupArgs; type Output = DmsetupOut; - type RunT = io::Result; + type RunT = Result>; fn run_privileged(&self) -> Self::RunT { - let mut cmd = self.bin.command(); - match &self.op { + let dm = &self.dmsetup; + Ok(match &self.op { DmOp::Create { name, readonly, table, } => { - cmd.arg("create").arg(name.to_string()); - if *readonly { - cmd.arg("--readonly"); + let access = if *readonly { + Access::ReadOnly + } else { + Access::ReadWrite + }; + DmsetupOut::Created { + device: dm.create(name.as_ref(), table, access)?.path(), } - cmd.arg("--table").arg(table.to_string()); } DmOp::Remove { retry, name } => { - cmd.arg("remove"); - if *retry { - cmd.arg("--retry"); - } - cmd.arg(name.to_string()); + let mode = if *retry { + RemoveMode::Retry + } else { + RemoveMode::Once + }; + dm.device(name.as_ref()).remove(mode)?; + DmsetupOut::Removed } DmOp::Message { name, message } => { - cmd.arg("message") - .arg(name.to_string()) - .arg("0") - .arg(message.to_string()); + let pool = dm.thin_pool(name.as_ref())?; + match *message { + ThinMessage::CreateThin(id) => pool.create_thin(id).map(drop)?, + ThinMessage::CreateSnap { id, origin } => pool.create_snap(id, origin)?, + ThinMessage::Delete(id) => pool.delete_thin(id)?, + ThinMessage::ReserveMetadataSnap => pool.reserve_metadata_snap()?, + ThinMessage::ReleaseMetadataSnap => pool.release_metadata_snap()?, + } + DmsetupOut::Messaged } DmOp::Suspend { name } => { - cmd.arg("suspend").arg(name.to_string()); + dm.device(name.as_ref()).suspend()?; + DmsetupOut::Suspended } DmOp::Resume { name } => { - cmd.arg("resume").arg(name.to_string()); - } - DmOp::Targets => { - cmd.arg("targets"); + dm.device(name.as_ref()).resume()?; + DmsetupOut::Resumed } - DmOp::Ls => { - cmd.arg("ls"); - } - } - - cmd.env_clear().output() - } - - fn parse(&self, res: Self::RunT) -> Result> { - let out = res.map_err(Error::Spawn)?; - if !out.status.success() { - return Err( - Error::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()).into(), - ); - } - - Ok(match &self.op { - DmOp::Create { name, .. } => DmsetupOut::Created { - device: PathBuf::from(format!("/dev/mapper/{name}")), - }, - DmOp::Remove { .. } => DmsetupOut::Removed, - DmOp::Message { .. } => DmsetupOut::Messaged, - DmOp::Suspend { .. } => DmsetupOut::Suspended, - DmOp::Resume { .. } => DmsetupOut::Resumed, DmOp::Targets => DmsetupOut::Targets { - output: String::from_utf8_lossy(&out.stdout).into_owned(), - }, - DmOp::Ls => DmsetupOut::Listed { - output: String::from_utf8_lossy(&out.stdout).into_owned(), + output: dm + .targets()? + .iter() + .map(|target| format!("{} {}\n", target.name, target.version)) + .collect(), }, + DmOp::Ls => DmsetupOut::Listed { output: dm.list()? }, }) } + + fn parse(&self, res: Self::RunT) -> Result> { + res + } } diff --git a/native/suidhelper/src/tools/dmsetup/snapshot.rs b/native/suidhelper/src/tools/dmsetup/snapshot.rs deleted file mode 100644 index 423b19ef..00000000 --- a/native/suidhelper/src/tools/dmsetup/snapshot.rs +++ /dev/null @@ -1,50 +0,0 @@ -use super::Error; -use crate::util::safe_dev::{BlockDev, LoopDev}; -use derive_more::Display; -use std::str::FromStr; - -/// A dm-snapshot table line: `0 snapshot P|N `. -/// Only this target is accepted - other dm targets (linear, crypt, ...) could map -/// arbitrary devices - and origin/cow are anchored to loop / hyper-* devices by -/// their types. Parsed from the caller's string, then rendered back via -/// `Display` so dmsetup only ever sees a table we reconstructed ourselves. -#[derive(Clone, Display)] -#[display("0 {sectors} snapshot {origin} {cow} {} {chunk}", if *persistent { "P" } else { "N" })] -pub struct SnapshotTable { - sectors: u64, - origin: BlockDev, - cow: LoopDev, - persistent: bool, - chunk: u64, -} - -impl FromStr for SnapshotTable { - type Err = Error; - - fn from_str(s: &str) -> Result { - let fields: Vec<&str> = s.split_whitespace().collect(); - let [start, sectors, "snapshot", origin, cow, mode, chunk] = fields.as_slice() else { - return Err(Error::BadTable(s.to_string())); - }; - - let persistent = match *mode { - "P" => true, - "N" => false, - _ => return Err(Error::BadTable(s.to_string())), - }; - - if *start != "0" { - return Err(Error::BadTable(s.to_string())); - } - - Ok(Self { - sectors: sectors - .parse() - .map_err(|_| Error::BadTable(s.to_string()))?, - origin: origin.parse()?, - cow: cow.parse()?, - persistent, - chunk: chunk.parse().map_err(|_| Error::BadTable(s.to_string()))?, - }) - } -} diff --git a/native/suidhelper/src/tools/dmsetup/table.rs b/native/suidhelper/src/tools/dmsetup/table.rs index 811e6a17..5365bc7f 100644 --- a/native/suidhelper/src/tools/dmsetup/table.rs +++ b/native/suidhelper/src/tools/dmsetup/table.rs @@ -1,28 +1,69 @@ -use super::snapshot::SnapshotTable; -use super::thin::ThinTable; -use super::thin_pool::ThinPoolTable; -use super::Error; -use derive_more::Display; -use std::str::FromStr; +use crate::linux::dm::{ + DmTable, Persistence, SnapshotTable, ThinId, ThinIdOutOfRange, ThinPoolTable, ThinTable, +}; +use crate::util::safe_dev::{self, BlockDev, LoopDev}; +use std::path::{Path, PathBuf}; +use thiserror::Error as ThisError; -/// Any dm table we are willing to create. The variant is chosen by the target -/// keyword; every variant re-renders from validated fields so dmsetup only ever -/// sees a table we reconstructed. -#[derive(Clone, Display)] -pub enum DmTable { - Snapshot(SnapshotTable), - ThinPool(ThinPoolTable), - Thin(ThinTable), +#[derive(Debug, ThisError)] +pub enum TableError { + #[error("unsupported dm table: {0}")] + Malformed(String), + #[error(transparent)] + Device(#[from] safe_dev::Error), + #[error(transparent)] + ThinId(#[from] ThinIdOutOfRange), } -impl FromStr for DmTable { - type Err = Error; - fn from_str(s: &str) -> Result { - match s.split_whitespace().nth(2) { - Some("snapshot") => Ok(DmTable::Snapshot(s.parse()?)), - Some("thin-pool") => Ok(DmTable::ThinPool(s.parse()?)), - Some("thin") => Ok(DmTable::Thin(s.parse()?)), - _ => Err(Error::BadTable(s.to_string())), +pub fn parse_table(s: &str) -> Result { + let malformed = || TableError::Malformed(s.to_string()); + let number = |t: &str| t.parse::().map_err(|_| malformed()); + let thin_id = |t: &str| -> Result { + Ok(ThinId::new(t.parse().map_err(|_| malformed())?)?) + }; + + let block = |t: &str| -> Result { + let dev: BlockDev = t.parse()?; + Ok(AsRef::::as_ref(&dev).to_path_buf()) + }; + let looped = |t: &str| -> Result { + let dev: LoopDev = t.parse()?; + Ok(AsRef::::as_ref(&dev).to_path_buf()) + }; + + let fields: Vec<&str> = s.split_whitespace().collect(); + Ok(match fields.as_slice() { + ["0", sectors, "snapshot", origin, cow, mode, chunk] => { + let persistence = match *mode { + "P" => Persistence::Persistent, + "N" => Persistence::Transient, + _ => return Err(malformed()), + }; + DmTable::Snapshot(SnapshotTable { + sectors: number(sectors)?, + origin: block(origin)?, + cow: looped(cow)?, + persistence, + chunk: number(chunk)?, + }) + } + ["0", sectors, "thin-pool", meta, data, block_sectors, low] => { + DmTable::ThinPool(ThinPoolTable { + sectors: number(sectors)?, + metadata: looped(meta)?, + data: looped(data)?, + block_sectors: number(block_sectors)?, + low_water: number(low)?, + }) + } + ["0", sectors, "thin", pool, id, origin @ ..] if origin.len() <= 1 => { + DmTable::Thin(ThinTable { + sectors: number(sectors)?, + pool: block(pool)?, + id: thin_id(id)?, + origin: origin.first().map(|o| block(o)).transpose()?, + }) } - } + _ => return Err(malformed()), + }) } diff --git a/native/suidhelper/src/tools/dmsetup/thin.rs b/native/suidhelper/src/tools/dmsetup/thin.rs deleted file mode 100644 index 5a31c91b..00000000 --- a/native/suidhelper/src/tools/dmsetup/thin.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::Error; -use crate::util::safe_dev::BlockDev; -use derive_more::Display; -use std::str::FromStr; - -/// A dm-thin table: `0 thin []`. -/// pool + origin are anchored to our own dm/loop devices. -#[derive(Clone, Display)] -#[display( - "0 {sectors} thin {pool} {dev_id}{}", - origin.as_ref().map(|o| format!(" {o}")).unwrap_or_default() -)] -pub struct ThinTable { - sectors: u64, - pool: BlockDev, - dev_id: u64, - origin: Option, -} - -impl FromStr for ThinTable { - type Err = Error; - fn from_str(s: &str) -> Result { - let f: Vec<&str> = s.split_whitespace().collect(); - let (sectors, pool, dev_id, origin) = match f.as_slice() { - ["0", sectors, "thin", pool, id] => (sectors, pool, id, None), - ["0", sectors, "thin", pool, id, origin] => (sectors, pool, id, Some(origin)), - _ => return Err(Error::BadTable(s.to_string())), - }; - Ok(Self { - sectors: sectors - .parse() - .map_err(|_| Error::BadTable(s.to_string()))?, - pool: pool.parse()?, - dev_id: dev_id.parse().map_err(|_| Error::BadTable(s.to_string()))?, - origin: origin.map(|o| o.parse()).transpose()?, - }) - } -} diff --git a/native/suidhelper/src/tools/dmsetup/thin_pool.rs b/native/suidhelper/src/tools/dmsetup/thin_pool.rs deleted file mode 100644 index 6f629145..00000000 --- a/native/suidhelper/src/tools/dmsetup/thin_pool.rs +++ /dev/null @@ -1,35 +0,0 @@ -use super::Error; -use crate::util::safe_dev::LoopDev; -use derive_more::Display; -use std::str::FromStr; - -/// A dm-thin-pool table: `0 thin-pool `. -/// meta/data are our own loop devices; no feature args are accepted. -#[derive(Clone, Display)] -#[display("0 {sectors} thin-pool {metadata} {data} {block_sectors} {low_water}")] -pub struct ThinPoolTable { - sectors: u64, - metadata: LoopDev, - data: LoopDev, - block_sectors: u64, - low_water: u64, -} - -impl FromStr for ThinPoolTable { - type Err = Error; - fn from_str(s: &str) -> Result { - let f: Vec<&str> = s.split_whitespace().collect(); - let ["0", sectors, "thin-pool", meta, data, block, low] = f.as_slice() else { - return Err(Error::BadTable(s.to_string())); - }; - Ok(Self { - sectors: sectors - .parse() - .map_err(|_| Error::BadTable(s.to_string()))?, - metadata: meta.parse()?, - data: data.parse()?, - block_sectors: block.parse().map_err(|_| Error::BadTable(s.to_string()))?, - low_water: low.parse().map_err(|_| Error::BadTable(s.to_string()))?, - }) - } -} diff --git a/native/suidhelper/src/tools/mod.rs b/native/suidhelper/src/tools/mod.rs index 63875433..aa0cb3fd 100644 --- a/native/suidhelper/src/tools/mod.rs +++ b/native/suidhelper/src/tools/mod.rs @@ -16,7 +16,9 @@ pub mod thin_dump; pub use blockcopy::{Blockcopy, BlockcopyArgs}; pub use blockdev::{Blockdev, BlockdevArgs}; pub use chroot_jail::ChrootJailOp; -pub use dmsetup::{DmTable, Dmsetup, DmsetupArgs, ThinMessage}; +pub use dmsetup::{ + parse_message, parse_table, Dmsetup, DmsetupArgs, MessageError, TableError, ThinMessage, +}; pub use losetup::{Losetup, LosetupArgs}; pub use network::NetworkOp; pub use thin_dump::{ThinDump, ThinDumpArgs}; diff --git a/native/suidhelper/src/util/jailer.rs b/native/suidhelper/src/util/jailer.rs index 8fbdbebf..ec4dd851 100644 --- a/native/suidhelper/src/util/jailer.rs +++ b/native/suidhelper/src/util/jailer.rs @@ -184,6 +184,18 @@ impl FromStr for JailSock { } } +impl AsRef for VmId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef for JailSock { + fn as_ref(&self) -> &str { + &self.0 + } +} + /// A fully-resolved jailer invocation. The privileged fields (`jailer`, /// `firecracker`, `jail_base`, `parent_cgroup`) must come from trusted config; /// the caller-supplied fields (`vm`, `uid`, `gid`, `cgroups`, `api_sock`) must @@ -211,10 +223,12 @@ impl Jailer<'_> { /// caller never names the jailer, the `--exec-file`, the chroot base, the /// cgroup version, or the parent cgroup — those come from trusted config. fn argv(&self) -> Result, Error> { + let vm: &str = self.vm.as_ref(); + let api_sock: &str = self.api_sock.as_ref(); let mut argv = vec![ "jailer".to_cstring()?, "--id".to_cstring()?, - self.vm.to_string().to_cstring()?, + vm.to_cstring()?, "--exec-file".to_cstring()?, self.firecracker.as_path().to_cstring()?, "--uid".to_cstring()?, @@ -241,7 +255,7 @@ impl Jailer<'_> { argv.push("--".to_cstring()?); argv.push("--api-sock".to_cstring()?); - argv.push(self.api_sock.to_string().to_cstring()?); + argv.push(api_sock.to_cstring()?); Ok(argv) } diff --git a/native/suidhelper/src/util/safe_dev.rs b/native/suidhelper/src/util/safe_dev.rs index 5f2f3999..80a9fcd5 100644 --- a/native/suidhelper/src/util/safe_dev.rs +++ b/native/suidhelper/src/util/safe_dev.rs @@ -101,3 +101,9 @@ impl FromStr for DmName { } } } + +impl AsRef for DmName { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/native/suidhelper/tests/e2e/argv.rs b/native/suidhelper/tests/e2e/argv.rs index 87fb3781..b1ef6ff2 100644 --- a/native/suidhelper/tests/e2e/argv.rs +++ b/native/suidhelper/tests/e2e/argv.rs @@ -110,7 +110,12 @@ fn dmsetup_message_create_thin_as_root() { } let tmp = tempfile::tempdir().unwrap(); let rec = tmp.path().join("argv.json"); - let bin = install_fake(tmp.path(), "dmsetup", &rec, ""); + let bin = install_fake( + tmp.path(), + "dmsetup", + &rec, + "0 100 thin-pool 7:0 7:1 128 0 0", + ); let cfg = write_root_config(tmp.path(), &[("dmsetup", &bin)]); run( @@ -147,7 +152,7 @@ fn dmsetup_targets_argv_and_parse_as_root() { assert_eq!(recorded_argv(&rec), vec!["targets"]); let json = stdout_json(&out); assert_eq!(json["result"], "targets"); - assert_eq!(json["output"], "snapshot v1.16.0\n"); + assert_eq!(json["output"], "snapshot v1.16.0\n"); } #[test] diff --git a/native/suidhelper/tests/linux/dm.rs b/native/suidhelper/tests/linux/dm.rs new file mode 100644 index 00000000..a9d53ff4 --- /dev/null +++ b/native/suidhelper/tests/linux/dm.rs @@ -0,0 +1,152 @@ +use hyper_suidhelper::linux::dm::ThinId; +use hyper_suidhelper::linux::dm::{Dmsetup, Inactive, SnapshotError, Thin, ThinPoolError}; +use hyper_suidhelper::process::binary::RootSafeBin; +use proptest::prelude::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use tempfile::TempDir; + +proptest! { + #[test] + fn thin_id_accepts_exactly_24_bit_values( + n in prop_oneof![0..=ThinId::MAX, (ThinId::MAX + 1)..=u32::MAX] + ) { + match ThinId::new(n) { + Ok(id) => { + prop_assert!(n <= ThinId::MAX); + prop_assert_eq!(id.to_string(), n.to_string()); + } + Err(_) => prop_assert!(n > ThinId::MAX), + } + } +} + +const POOL_TABLE: &str = "0 100 thin-pool 7:0 7:1 128 0 0"; + +fn is_root() -> bool { + nix::unistd::geteuid().is_root() +} + +struct Fake { + _dir: TempDir, + log: PathBuf, + dmsetup: Dmsetup, +} + +impl Fake { + fn new(table: &str, failing_message: &str) -> Self { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("calls"); + let bin = dir.path().join("dmsetup"); + let script = format!( + "#!/bin/sh\n\ + printf '%s\\n' \"$*\" >> '{log}'\n\ + case \"$1\" in\n\ + table) printf '%s\\n' '{table}' ;;\n\ + message) [ \"$4\" = '{failing_message}' ] && exit 1 ;;\n\ + esac\n\ + exit 0\n", + log = log.display(), + ); + fs::write(&bin, script).unwrap(); + fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap(); + let dmsetup = Dmsetup::new(RootSafeBin::open(&bin).unwrap()); + Self { + _dir: dir, + log, + dmsetup, + } + } + + fn calls(&self) -> Vec { + fs::read_to_string(&self.log) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() + } +} + +#[test] +fn thin_pool_refuses_a_device_that_is_not_a_pool_as_root() { + if !is_root() { + eprintln!("SKIP thin_pool_refuses: needs root to own the fake dmsetup"); + return; + } + for table in [ + "0 100 linear 7:0 0", + "0 100 thin /dev/mapper/hyper-pool 1", + "", + ] { + let fake = Fake::new(table, ""); + let got = fake.dmsetup.thin_pool("hyper-x"); + assert!( + matches!(got, Err(ThinPoolError::NotAThinPool { .. })), + "{table:?}: {got:?}" + ); + assert_eq!(fake.calls(), ["table hyper-x"], "{table:?}"); + } +} + +#[test] +fn snapshot_always_resumes_the_origin_as_root() { + if !is_root() { + eprintln!("SKIP snapshot_always_resumes: needs root to own the fake dmsetup"); + return; + } + let ok: fn(&Result, SnapshotError>) -> bool = |r| { + r.as_ref() + .is_ok_and(|snap| snap.id() == ThinId::new(2).unwrap()) + }; + let create_snap_failed: fn(&Result, SnapshotError>) -> bool = + |r| matches!(r, Err(SnapshotError::CreateSnap(_))); + for (failing_message, expected) in [("", ok), ("create_snap 2 1", create_snap_failed)] { + let fake = Fake::new(POOL_TABLE, failing_message); + let pool = fake.dmsetup.thin_pool("hyper-pool").unwrap(); + let active = pool + .create_thin(ThinId::new(1).unwrap()) + .unwrap() + .activate("hyper-vol", 100, None) + .unwrap(); + + let got = active.snapshot(ThinId::new(2).unwrap()); + + assert!(expected(&got), "{failing_message:?}: {got:?}"); + assert_eq!( + fake.calls(), + [ + "table hyper-pool", + "message hyper-pool 0 create_thin 1", + "create hyper-vol --table 0 100 thin /dev/mapper/hyper-pool 1", + "suspend hyper-vol", + "message hyper-pool 0 create_snap 2 1", + "resume hyper-vol", + ], + "{failing_message:?}" + ); + } +} + +#[test] +fn deactivate_then_delete_removes_the_device_then_frees_its_id_as_root() { + if !is_root() { + eprintln!("SKIP delete_removes: needs root to own the fake dmsetup"); + return; + } + let fake = Fake::new(POOL_TABLE, ""); + let pool = fake.dmsetup.thin_pool("hyper-pool").unwrap(); + pool.create_thin(ThinId::new(1).unwrap()) + .unwrap() + .activate("hyper-vol", 100, None) + .unwrap() + .deactivate() + .unwrap() + .delete() + .unwrap(); + + assert_eq!( + fake.calls()[3..], + ["remove --retry hyper-vol", "message hyper-pool 0 delete 1"] + ); +} diff --git a/native/suidhelper/tests/tools/dmsetup_parsers.rs b/native/suidhelper/tests/tools/dmsetup_parsers.rs index b597484f..3b3f92f9 100644 --- a/native/suidhelper/tests/tools/dmsetup_parsers.rs +++ b/native/suidhelper/tests/tools/dmsetup_parsers.rs @@ -5,7 +5,12 @@ //! malformed arity. These are the contract that keeps dmsetup off arbitrary //! storage. -use hyper_suidhelper::tools::{DmTable, ThinMessage}; +use hyper_suidhelper::linux::dm::ThinId; +use hyper_suidhelper::tools::{parse_message, parse_table, ThinMessage}; + +fn id(n: u32) -> ThinId { + ThinId::new(n).unwrap() +} #[test] fn accepts_canonical_tables_and_round_trips() { @@ -14,10 +19,9 @@ fn accepts_canonical_tables_and_round_trips() { "0 100 thin-pool /dev/loop0 /dev/loop1 128 1024", "0 100 thin /dev/mapper/hyper-pool 0", "0 100 thin /dev/mapper/hyper-pool 0 /dev/mapper/hyper-orig", + "0 100 thin /dev/mapper/hyper-pool 16777215", ] { - let t = s - .parse::() - .unwrap_or_else(|_| panic!("rejected {s:?}")); + let t = parse_table(s).unwrap_or_else(|_| panic!("rejected {s:?}")); assert_eq!(t.to_string(), s, "round-trip changed {s:?}"); } } @@ -26,9 +30,7 @@ fn accepts_canonical_tables_and_round_trips() { fn normalizes_inner_whitespace_on_render() { let weird = "0 100 snapshot /dev/loop0 /dev/loop1 P 8"; let canonical = "0 100 snapshot /dev/loop0 /dev/loop1 P 8"; - let t = weird - .parse::() - .expect("weird spacing must still parse"); + let t = parse_table(weird).expect("weird spacing must still parse"); assert_eq!(t.to_string(), canonical); } @@ -44,29 +46,49 @@ fn rejects_non_whitelisted_targets_and_devices() { "0 100 snapshot /dev/loop0 /dev/loop1 P", // too few fields "0 100 thin-pool /dev/sda /dev/loop1 128 1024", // meta = arbitrary "0 100 thin /dev/sda 0", // pool = arbitrary + "0 100 thin /dev/mapper/hyper-pool 16777216", // id past 24 bits + "0 100 thin /dev/mapper/hyper-pool 0 /dev/sda", // origin = arbitrary + "0 100 thin /dev/mapper/hyper-pool 0 /dev/loop0 /dev/loop1", // two origins "", // empty "garbage", // junk ] { - assert!(bad.parse::().is_err(), "DmTable accepted {bad:?}"); + assert!(parse_table(bad).is_err(), "parse_table accepted {bad:?}"); } } #[test] fn thinmessage_accepts_whitelisted_and_normalizes() { - for (s, canon) in [ - ("create_thin 7", "create_thin 7"), - ("create_thin 7", "create_thin 7"), - ("delete 3", "delete 3"), - ("create_snap 4 2", "create_snap 4 2"), - ("create_snap 4 2", "create_snap 4 2"), - ("reserve_metadata_snap", "reserve_metadata_snap"), - (" reserve_metadata_snap ", "reserve_metadata_snap"), - ("release_metadata_snap", "release_metadata_snap"), + for (s, want) in [ + ("create_thin 7", ThinMessage::CreateThin(id(7))), + ("create_thin 7", ThinMessage::CreateThin(id(7))), + ( + "create_thin 16777215", + ThinMessage::CreateThin(id(16777215)), + ), + ("delete 3", ThinMessage::Delete(id(3))), + ( + "create_snap 4 2", + ThinMessage::CreateSnap { + id: id(4), + origin: id(2), + }, + ), + ( + "create_snap 4 2", + ThinMessage::CreateSnap { + id: id(4), + origin: id(2), + }, + ), + ("reserve_metadata_snap", ThinMessage::ReserveMetadataSnap), + ( + " reserve_metadata_snap ", + ThinMessage::ReserveMetadataSnap, + ), + ("release_metadata_snap", ThinMessage::ReleaseMetadataSnap), ] { - let m = s - .parse::() - .unwrap_or_else(|_| panic!("rejected {s:?}")); - assert_eq!(m.to_string(), canon, "round-trip changed {s:?}"); + let got = parse_message(s).unwrap_or_else(|_| panic!("rejected {s:?}")); + assert_eq!(got, want, "{s:?}"); } } @@ -89,10 +111,13 @@ fn thinmessage_rejects_non_whitelisted() { "reserve_metadata_snap 1", "release_metadata_snap 1 2", "reserve_metadata", + "create_thin 16777216", + "delete 16777216", + "create_snap 1 16777216", ] { assert!( - bad.parse::().is_err(), - "ThinMessage accepted {bad:?}" + parse_message(bad).is_err(), + "parse_message accepted {bad:?}" ); } } From 074aff29615abeb4c089cc8db583e2dc03406f7b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 25 Oct 2026 19:46:34 -0700 Subject: [PATCH 02/17] refactor: remove unnecessary utilities --- native/suidhelper/Cargo.toml | 2 +- native/suidhelper/src/config.rs | 7 --- native/suidhelper/src/fs/file.rs | 21 +++++++++ native/suidhelper/src/tools/blockdev.rs | 63 +++++++++++-------------- native/suidhelper/src/tools/mod.rs | 5 +- native/suidhelper/tests/e2e/argv.rs | 44 +++++++++++++---- 6 files changed, 87 insertions(+), 55 deletions(-) diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 14632eb4..90fee95a 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -141,7 +141,7 @@ bon = "3.9.3" clap = { version = "4", features = ["derive"] } derive_more = { version = "2.1.1", features = ["display", "as_ref", "into", "add", "debug"] } hyper-suidhelper-meta = { path = "meta" } -nix = { version = "0.29", features = ["user", "fs", "dir", "process"] } +nix = { version = "0.29", features = ["user", "fs", "dir", "process", "ioctl"] } quick-xml = { version = "0.37", features = ["serialize"] } ref-cast = "1.0.25" serde = { version = "1", features = ["derive"] } diff --git a/native/suidhelper/src/config.rs b/native/suidhelper/src/config.rs index e915cd16..6582b869 100644 --- a/native/suidhelper/src/config.rs +++ b/native/suidhelper/src/config.rs @@ -137,8 +137,6 @@ pub struct Tools { dmsetup: PathBuf, #[default("/usr/sbin/losetup".into())] losetup: PathBuf, - #[default("/usr/sbin/blockdev".into())] - blockdev: PathBuf, // /usr/bin/ip, not /usr/sbin/ip: on merged-usr distros the latter // is a symlink to the former, and RootSafeBin rejects symlinked tool // paths. iproute2's real binary lives at /usr/bin/ip. @@ -227,11 +225,6 @@ impl Config { RootSafeBin::open(&self.tools.losetup) } - /// The validated `blockdev` binary the helper will run. - pub fn blockdev(&self) -> Result, binary::Error> { - RootSafeBin::open(&self.tools.blockdev) - } - /// The validated `ip` (iproute2) binary the network tool runs. pub fn ip(&self) -> Result, binary::Error> { RootSafeBin::open(&self.tools.ip) diff --git a/native/suidhelper/src/fs/file.rs b/native/suidhelper/src/fs/file.rs index e62f441e..381fd61d 100644 --- a/native/suidhelper/src/fs/file.rs +++ b/native/suidhelper/src/fs/file.rs @@ -15,6 +15,16 @@ use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; use std::path::PathBuf; use thiserror::Error as ThisError; +const SECTOR_BYTES: u64 = 512; + +mod ioctl { + nix::ioctl_read_bad!( + blkgetsize64, + nix::request_code_read!(0x12, 114, std::mem::size_of::()), + u64 + ); +} + /// The single error type shared by every `SafeFile` flavor. A given flavor only ever yields the /// variants for the axes it actually enforces. #[derive(Debug, Clone, Copy, ThisError)] @@ -205,6 +215,17 @@ impl SafeFile { let stat = fstat(self.0.as_raw_fd()).map_err(ValidationError::Fstat)?; Ok(makedev(major(stat.st_rdev), minor(stat.st_rdev))) } + + pub fn size(&self) -> nix::Result { + let mut bytes = 0u64; + // SAFETY: BLKGETSIZE64 writes exactly one u64 through the pointer. + unsafe { ioctl::blkgetsize64(self.0.as_raw_fd(), &mut bytes) }?; + Ok(bytes) + } + + pub fn sectors(&self) -> nix::Result { + Ok(self.size()? / SECTOR_BYTES) + } } impl SafeFile diff --git a/native/suidhelper/src/tools/blockdev.rs b/native/suidhelper/src/tools/blockdev.rs index 0761d1ce..fd25bc67 100644 --- a/native/suidhelper/src/tools/blockdev.rs +++ b/native/suidhelper/src/tools/blockdev.rs @@ -1,22 +1,27 @@ use super::IsTool; -use crate::process::binary::RootSafeBin; +use crate::fs::file::{self, Any, IsBlockDevice, OpenFlags, SafeFile}; +use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; use crate::util::safe_dev::BlockDev; use clap::Args; use serde::Serialize; use std::io; -use std::num::ParseIntError; use std::path::PathBuf; -use std::process::Output; use thiserror::Error as ThisError; #[derive(Debug, ThisError)] pub enum Error { - #[error("running blockdev: {0}")] - Spawn(#[source] io::Error), - #[error("blockdev failed: {0}")] - Failed(String), - #[error("parsing size: {0}")] - ParseSize(#[from] ParseIntError), + #[error("resolving {path}: {source}")] + Resolve { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("device path: {0}")] + Path(#[from] path::ValidationError), + #[error("opening device: {0}")] + Open(#[from] file::ValidationError), + #[error("querying device size: {0}")] + Size(#[source] nix::Error), } #[derive(Args)] @@ -33,47 +38,35 @@ pub struct BlockdevOut { } pub struct Blockdev { - bin: RootSafeBin<"blockdev">, - path: PathBuf, + path: BlockDev, } impl Blockdev { - pub fn new(bin: RootSafeBin<"blockdev">, args: BlockdevArgs) -> Self { + pub fn new(args: BlockdevArgs) -> Self { // clap required --getsz; the path was validated as a BlockDev. let BlockdevArgs { getsz: _, path } = args; - Self { - bin, - path: path.into(), - } + Self { path } } } impl IsTool for Blockdev { type Args = BlockdevArgs; type Output = BlockdevOut; - type RunT = io::Result; + type RunT = Result; fn run_privileged(&self) -> Self::RunT { - self.bin - .command() - .arg("--getsz") - .arg(&self.path) - .env_clear() - .output() + let dev: &std::path::Path = self.path.as_ref(); + let real = std::fs::canonicalize(dev).map_err(|source| Error::Resolve { + path: dev.to_path_buf(), + source, + })?; + let safe: &SafePath = real.as_path().try_into()?; + let file = SafeFile::::open(safe, OpenFlags::RDONLY)?; + let sectors = file.sectors().map_err(Error::Size)?; + Ok(BlockdevOut { sectors }) } fn parse(&self, res: Self::RunT) -> Result> { - let out = res.map_err(Error::Spawn)?; - if !out.status.success() { - return Err( - Error::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()).into(), - ); - } - - let sectors = String::from_utf8_lossy(&out.stdout) - .trim() - .parse::() - .map_err(Error::ParseSize)?; - Ok(BlockdevOut { sectors }) + Ok(res?) } } diff --git a/native/suidhelper/src/tools/mod.rs b/native/suidhelper/src/tools/mod.rs index aa0cb3fd..961a8fb3 100644 --- a/native/suidhelper/src/tools/mod.rs +++ b/native/suidhelper/src/tools/mod.rs @@ -133,10 +133,7 @@ impl Tool { let bin = config.dmsetup().map_err(|e| Error::Tool(Box::new(e)))?; Dmsetup::new(bin, args).run() } - Tool::Blockdev { args } => { - let bin = config.blockdev().map_err(|e| Error::Tool(Box::new(e)))?; - Blockdev::new(bin, args).run() - } + Tool::Blockdev { args } => Blockdev::new(args).run(), Tool::Blockcopy { args } => Blockcopy::new(args).run(), Tool::ChrootJail { op } => op.run(), Tool::Network { op } => op.run(), diff --git a/native/suidhelper/tests/e2e/argv.rs b/native/suidhelper/tests/e2e/argv.rs index b1ef6ff2..9dc4838c 100644 --- a/native/suidhelper/tests/e2e/argv.rs +++ b/native/suidhelper/tests/e2e/argv.rs @@ -223,20 +223,48 @@ fn dmsetup_rejects_configured_bin_with_wrong_basename_as_root() { .stderr(contains("basename must be")); } +struct LoopDevice(String); + +impl LoopDevice { + fn attach(backing: &Path) -> Self { + let out = std::process::Command::new("losetup") + .args(["--find", "--show"]) + .arg(backing) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + Self(String::from_utf8(out.stdout).unwrap().trim().to_string()) + } +} + +impl Drop for LoopDevice { + fn drop(&mut self) { + let _ = std::process::Command::new("losetup") + .args(["--detach", &self.0]) + .status(); + } +} + #[test] -fn blockdev_getsz_argv_and_parse_as_root() { +fn blockdev_getsz_reports_a_loop_devices_size_as_root() { if !is_root() { eprintln!("SKIP blockdev: needs root"); return; } let tmp = tempfile::tempdir().unwrap(); - let rec = tmp.path().join("argv.json"); - // Fake prints "2048" as the sector count for the helper to parse. - let bin = install_fake(tmp.path(), "blockdev", &rec, "2048"); - let cfg = write_root_config(tmp.path(), &[("blockdev", &bin)]); + let backing = tmp.path().join("backing"); + fs::File::create(&backing) + .unwrap() + .set_len(3 * 1024 * 1024 + 512) + .unwrap(); + let dev = LoopDevice::attach(&backing); + let cfg = write_root_config(tmp.path(), &[]); - let out = run(&cfg, &["blockdev", "--getsz", "/dev/loop0"]).success(); - assert_eq!(recorded_argv(&rec), vec!["--getsz", "/dev/loop0"]); + let out = run(&cfg, &["blockdev", "--getsz", &dev.0]).success(); let json = stdout_json(&out); - assert_eq!(json["sectors"], 2048); + assert_eq!(json["sectors"], 6145); } From 9da339e99775ca0c4f742f07d31215473ea721c7 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 25 Oct 2026 19:49:49 -0700 Subject: [PATCH 03/17] remove blockdev configuration --- docs/.DS_Store | Bin 0 -> 6148 bytes docs/cookbook/config.md | 2 -- docs/cookbook/install.md | 3 +-- lib/hyper/cfg/tools.ex | 7 +++---- lib/hyper/suid_helper.ex | 4 ++-- native/suidhelper/src/config.rs | 2 +- 6 files changed, 7 insertions(+), 11 deletions(-) create mode 100644 docs/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e788edcddd080f046c6c4ed6f9091bbdab426850 GIT binary patch literal 6148 zcmeHK%SyvQ6rE|SO({YaiY^D-7VJY5aT8+w0VBFlsR;=hjG2<8W>E^+>JRxRevkLg zOu*8vMeMyWbMA8{b0G6zjB$4s4j8i;V-_?-j>;NA_u5dyBqMShBcBF|48ZyfrY82+ z0l&S;G8VBsD1Ux`66Zy~|HT{4*4B30YFl0F!F!gaSNO#&cm3=Rt!pWhpw|81CXUL9 zy?Y_k!jID^Qw4DtLCXDYoQAS=84?;;w literal 0 HcmV?d00001 diff --git a/docs/cookbook/config.md b/docs/cookbook/config.md index 1ede588f..3e0e66bf 100644 --- a/docs/cookbook/config.md +++ b/docs/cookbook/config.md @@ -51,7 +51,6 @@ configurable: | `jailer` | - | `.jailer` | - | [Safe Path](#safe-path) | | `dmsetup` | - | `.dmsetup` | `"/usr/sbin/dmsetup"` | [Safe Path](#safe-path) | | `losetup` | - | `.losetup` | `"/usr/sbin/losetup"` | [Safe Path](#safe-path) | -| `blockdev` | - | `.blockdev` | `"/usr/sbin/blockdev"` | [Safe Path](#safe-path) | | `thin_dump` | - | `.thin_dump` | `"/usr/sbin/thin_dump"` | [Safe Path](#safe-path). Reads thin snapshot metadata for fork publish; required for `Hyper.Vm.fork/1`'s cross-node path (same-node `fast_fork/1` is unaffected). | | `mke2fs` | `.mke2fs` | `.mke2fs` | `$PATH["mke2fs"]` | | | `skopeo` | `.skopeo` | `.skopeo` | `$PATH["skopeo"]` | | @@ -80,7 +79,6 @@ firecracker = "/opt/firecracker/firecracker" jailer = "/opt/firecracker/jailer" dmsetup = "/usr/sbin/dmsetup" losetup = "/usr/sbin/losetup" -blockdev = "/usr/sbin/blockdev" thin_dump = "/usr/sbin/thin_dump" # Node tools -- may also be set in config.exs, which wins when both are set. diff --git a/docs/cookbook/install.md b/docs/cookbook/install.md index fe555d98..d39a0d41 100644 --- a/docs/cookbook/install.md +++ b/docs/cookbook/install.md @@ -155,7 +155,7 @@ work_dir = "/srv/hyper" # Paths to every external binary hyper uses. All paths must be absolute. # # The privileged binaries the setuid helper runs (firecracker, jailer, dmsetup, -# losetup, blockdev) must be root-owned and not group/world writable -- the +# losetup) must be root-owned and not group/world writable -- the # helper refuses them otherwise. The node-run tools (skopeo, umoci, mke2fs) have # no such requirement. [tools] @@ -168,7 +168,6 @@ jailer = "/opt/firecracker/jailer" # optional -- privileged device tools, default to /usr/sbin/. # dmsetup = "/usr/sbin/dmsetup" # losetup = "/usr/sbin/losetup" -# blockdev = "/usr/sbin/blockdev" # optional -- node-run tools. skopeo/mke2fs default to the name on PATH; omit # umoci to let hyper download and cache a pinned release. diff --git a/lib/hyper/cfg/tools.ex b/lib/hyper/cfg/tools.ex index 0cfa7565..3aa3d8cf 100644 --- a/lib/hyper/cfg/tools.ex +++ b/lib/hyper/cfg/tools.ex @@ -2,10 +2,9 @@ defmodule Hyper.Cfg.Tools do @moduledoc """ Paths to every external binary Hyper runs, read from the `[tools]` table. - The privileged tools (`firecracker`, `jailer`, `dmsetup`, `losetup`, - `blockdev`) are read **only** from `/etc/hyper/config.toml` — the file the - setuid helper also parses, so node and helper can never disagree on a - root-impacting path. The node-only tools (`skopeo`, `mke2fs`, `umoci`, + The privileged tools (`firecracker`, `jailer`, `dmsetup`, `losetup`) are read + **only** from `/etc/hyper/config.toml` — the file the setuid helper also + parses, so node and helper can never disagree on a root-impacting path. The node-only tools (`skopeo`, `mke2fs`, `umoci`, `suidhelper`) run unprivileged, so `/etc/hyper/config.exs` may override them (e.g. a path from a secrets manager), then `config.toml`, then the default. """ diff --git a/lib/hyper/suid_helper.ex b/lib/hyper/suid_helper.ex index 019bc31b..9d223a68 100644 --- a/lib/hyper/suid_helper.ex +++ b/lib/hyper/suid_helper.ex @@ -56,8 +56,8 @@ defmodule Hyper.SuidHelper do exposes the device-mapper targets we need (`Dmsetup.test_system/0`, which also exercises the helper's configured `dmsetup` binary). - The `losetup`/`blockdev` binaries are validated by the helper the first time - each is used; their paths live in the helper's own config, not here. + The `losetup` binary is validated by the helper the first time it is used; its + path lives in the helper's own config, not here. """ @spec test_system() :: :ok | {:error, term()} @decorate with_span("Hyper.SuidHelper.test_system") diff --git a/native/suidhelper/src/config.rs b/native/suidhelper/src/config.rs index 6582b869..f94f64ea 100644 --- a/native/suidhelper/src/config.rs +++ b/native/suidhelper/src/config.rs @@ -123,7 +123,7 @@ pub struct Jails { /// Paths to the external binaries the helper runs, the `[tools]` table. /// -/// The device tools (`dmsetup`, `losetup`, `blockdev`, `thin_dump`) carry +/// The device tools (`dmsetup`, `losetup`, `thin_dump`) carry /// built-in defaults; `firecracker` and `jailer` have none and must be /// configured before any VM can launch — their absence surfaces as /// [`BinError::Unconfigured`] at use time, not at load. Every path is From 627ea0a3e76fe5c77de496a1877b03b7a445fe0d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 25 Oct 2026 19:49:56 -0700 Subject: [PATCH 04/17] deslop --- docs/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index e788edcddd080f046c6c4ed6f9091bbdab426850..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%SyvQ6rE|SO({YaiY^D-7VJY5aT8+w0VBFlsR;=hjG2<8W>E^+>JRxRevkLg zOu*8vMeMyWbMA8{b0G6zjB$4s4j8i;V-_?-j>;NA_u5dyBqMShBcBF|48ZyfrY82+ z0l&S;G8VBsD1Ux`66Zy~|HT{4*4B30YFl0F!F!gaSNO#&cm3=Rt!pWhpw|81CXUL9 zy?Y_k!jID^Qw4DtLCXDYoQAS=84?;;w From 676fbc5fb2d56c9e1c4caadf90f6f25ec52f886e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 25 Oct 2026 20:11:04 -0700 Subject: [PATCH 05/17] create a thin-dump wrapper in linux/ --- native/suidhelper/Cargo.toml | 4 - native/suidhelper/src/fs/file.rs | 8 +- native/suidhelper/src/linux/dm/mod.rs | 1 + native/suidhelper/src/linux/dm/thin.rs | 6 + native/suidhelper/src/linux/dm/thin_dump.rs | 186 ++++++++++++++++++ native/suidhelper/src/tools/thin_dump.rs | 60 +++--- native/suidhelper/src/util/linux/mod.rs | 3 - native/suidhelper/src/util/linux/thin_dump.rs | 133 ------------- native/suidhelper/src/util/mod.rs | 1 - native/suidhelper/tests/e2e/thin_dump.rs | 13 +- .../suidhelper/tests/util/linux/thin_dump.rs | 186 ------------------ 11 files changed, 241 insertions(+), 360 deletions(-) create mode 100644 native/suidhelper/src/linux/dm/thin_dump.rs delete mode 100644 native/suidhelper/src/util/linux/mod.rs delete mode 100644 native/suidhelper/src/util/linux/thin_dump.rs delete mode 100644 native/suidhelper/tests/util/linux/thin_dump.rs diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 90fee95a..4a0fb703 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -128,10 +128,6 @@ path = "tests/config_uid_gid_range.rs" name = "config_network" path = "tests/config_network.rs" -[[test]] -name = "util_linux_thin_dump" -path = "tests/util/linux/thin_dump.rs" - [[test]] name = "tools_losetup_backing" path = "tests/tools/losetup_backing.rs" diff --git a/native/suidhelper/src/fs/file.rs b/native/suidhelper/src/fs/file.rs index 381fd61d..b6ca07ac 100644 --- a/native/suidhelper/src/fs/file.rs +++ b/native/suidhelper/src/fs/file.rs @@ -8,7 +8,7 @@ use nix::libc::{dev_t, AT_FDCWD}; use nix::sys::stat::{ fchmodat, fstat, major, makedev, minor, FchmodatFlags, FileStat, Mode, SFlag, }; -use nix::unistd::{fchownat, Gid, Uid}; +use nix::unistd::{dup, fchownat, Gid, Uid}; use std::io; use std::marker::PhantomData; use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; @@ -192,6 +192,12 @@ impl SafeFile { Ok(Self(self.0.try_clone()?, PhantomData)) } + pub fn inheritable(&self) -> nix::Result { + let raw = dup(self.0.as_raw_fd())?; + // SAFETY: `dup` just returned this fd and nothing else owns it. + Ok(unsafe { OwnedFd::from_raw_fd(raw) }) + } + pub fn chmod(&self, mode: Mode) -> nix::Result<()> { let path = PathBuf::from(format!("/proc/self/fd/{}", self.0.as_raw_fd())); // The FollowSymlink here is necessary because glibc can't not follow symlinks diff --git a/native/suidhelper/src/linux/dm/mod.rs b/native/suidhelper/src/linux/dm/mod.rs index c8a271be..078e292a 100644 --- a/native/suidhelper/src/linux/dm/mod.rs +++ b/native/suidhelper/src/linux/dm/mod.rs @@ -3,6 +3,7 @@ mod device; mod table; mod thin; +pub mod thin_dump; use crate::process::binary::RootSafeBin; pub use device::Device; diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs index bf42190e..232d1ad5 100644 --- a/native/suidhelper/src/linux/dm/thin.rs +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -22,6 +22,12 @@ impl ThinId { } } +impl From for u64 { + fn from(id: ThinId) -> Self { + u64::from(id.0) + } +} + impl fmt::Display for ThinId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) diff --git a/native/suidhelper/src/linux/dm/thin_dump.rs b/native/suidhelper/src/linux/dm/thin_dump.rs new file mode 100644 index 00000000..79a19c6e --- /dev/null +++ b/native/suidhelper/src/linux/dm/thin_dump.rs @@ -0,0 +1,186 @@ +//! `thin-dump` utilities. +use super::ThinId; +use crate::fs::file::{IsBlockDevice, SafeFile}; +use crate::process::binary::RootSafeBin; +use serde::Deserialize; +use std::io; +use std::os::unix::io::AsRawFd; +use thiserror::Error as ThisError; + +#[derive(Debug, ThisError)] +pub enum DumpError { + #[error("passing the metadata device: {0}")] + Inherit(#[source] nix::Error), + #[error("running thin_dump: {0}")] + Spawn(#[source] io::Error), + #[error("thin_dump failed: {0}")] + Failed(String), +} + +#[derive(Debug, ThisError)] +pub enum ParseError { + #[error("thin_dump output is not UTF-8: {0}")] + Utf8(#[source] std::str::Utf8Error), + #[error("thin_dump output is not valid metadata XML: {0}")] + Xml(String), +} + +#[derive(Debug, ThisError)] +pub enum MappingsError { + #[error("thin device {0} not present in the metadata dump")] + DeviceNotFound(ThinId), + #[error( + "device mappings incomplete: expected {expected} blocks, parsed {got} (truncated dump?)" + )] + Truncated { expected: u64, got: u64 }, +} + +pub struct ThinDump { + bin: RootSafeBin<"thin_dump">, +} + +impl ThinDump { + pub fn new(bin: RootSafeBin<"thin_dump">) -> Self { + Self { bin } + } + + pub fn dump( + &self, + metadata: &SafeFile, + id: ThinId, + ) -> Result { + let inherited = metadata.inheritable().map_err(DumpError::Inherit)?; + let out = self + .bin + .command() + .args(["--metadata-snap", "--dev-id", &id.to_string()]) + .arg(format!("/proc/self/fd/{}", inherited.as_raw_fd())) + .env_clear() + .output() + .map_err(DumpError::Spawn)?; + + if !out.status.success() { + return Err(DumpError::Failed( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + + Ok(MetadataDump { xml: out.stdout }) + } +} + +#[derive(Debug, Clone)] +pub struct MetadataDump { + xml: Vec, +} + +impl MetadataDump { + pub fn parse(&self) -> Result { + let xml = std::str::from_utf8(&self.xml).map_err(ParseError::Utf8)?; + let Root::Superblock(superblock) = + quick_xml::de::from_str(xml).map_err(|err| ParseError::Xml(err.to_string()))?; + Ok(superblock) + } +} + +impl Superblock { + pub fn mappings(&self, id: ThinId) -> Result { + let device = self + .devices + .iter() + .find(|device| device.dev_id == u64::from(id)) + .ok_or(MappingsError::DeviceNotFound(id))?; + + let ranges: Vec = device.mappings.iter().map(Mapping::range).collect(); + + let got: u64 = ranges.iter().map(|range| range.length).sum(); + if got != device.mapped_blocks { + return Err(MappingsError::Truncated { + expected: device.mapped_blocks, + got, + }); + } + + Ok(ThinMappings { + block_sectors: self.data_block_size, + ranges, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ThinMappings { + pub block_sectors: u64, + pub ranges: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(into = "(u64, u64)")] +pub struct BlockRange { + pub begin: u64, + pub length: u64, +} + +impl From for (u64, u64) { + fn from(range: BlockRange) -> Self { + (range.begin, range.length) + } +} + +#[derive(Debug, Deserialize)] +enum Root { + #[serde(rename = "superblock")] + Superblock(Superblock), +} + +#[derive(Debug, Deserialize)] +pub struct Superblock { + #[serde(rename = "@data_block_size")] + data_block_size: u64, + #[serde(rename = "device", default)] + devices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Device { + #[serde(rename = "@dev_id")] + dev_id: u64, + #[serde(rename = "@mapped_blocks")] + mapped_blocks: u64, + #[serde(rename = "$value", default)] + mappings: Vec, +} + +#[derive(Debug, Deserialize)] +enum Mapping { + #[serde(rename = "range_mapping")] + Range { + #[serde(rename = "@origin_begin")] + origin_begin: u64, + #[serde(rename = "@length")] + length: u64, + }, + #[serde(rename = "single_mapping")] + Single { + #[serde(rename = "@origin_block")] + origin_block: u64, + }, +} + +impl Mapping { + fn range(&self) -> BlockRange { + match *self { + Mapping::Range { + origin_begin, + length, + } => BlockRange { + begin: origin_begin, + length, + }, + Mapping::Single { origin_block } => BlockRange { + begin: origin_block, + length: 1, + }, + } + } +} diff --git a/native/suidhelper/src/tools/thin_dump.rs b/native/suidhelper/src/tools/thin_dump.rs index 25f01159..bf2404b9 100644 --- a/native/suidhelper/src/tools/thin_dump.rs +++ b/native/suidhelper/src/tools/thin_dump.rs @@ -7,28 +7,27 @@ //! implementations (the original C++ and the `pdata_tools` Rust rewrite) //! whose formatting can drift (attribute order, whitespace, escaping) without //! changing meaning, and a grammar-level parser is immune to that drift. See -//! `crate::util::linux::thin_dump` for the document model. +//! `crate::linux::dm::thin_dump` for the document model. -use std::io; -use std::process::Output; use thiserror::Error as ThisError; use super::IsTool; +use crate::fs::file::{self, Any, IsBlockDevice, OpenFlags, SafeFile}; +use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::linux::dm::thin_dump::{self as dump, DumpError, MetadataDump, ThinMappings}; +use crate::linux::dm::ThinId; use crate::process::binary::RootSafeBin; -use crate::util::linux::thin_dump::{parse_mappings, Error as ParseError, ThinMappings}; use crate::util::safe_dev::LoopDev; use clap::Args; -/// Errors of running the `thin_dump` binary itself; a bad parse of its -/// output composes in via `Parse`. #[derive(Debug, ThisError)] pub enum ToolError { - #[error("running thin_dump: {0}")] - Spawn(#[source] io::Error), - #[error("thin_dump failed: {0}")] - Failed(String), + #[error("metadata device path: {0}")] + Path(#[from] path::ValidationError), + #[error("opening metadata device: {0}")] + Open(#[from] file::ValidationError), #[error(transparent)] - Parse(#[from] ParseError), + Dump(#[from] DumpError), } #[derive(Args)] @@ -37,46 +36,45 @@ pub struct ThinDumpArgs { #[arg(long)] meta: LoopDev, /// The thin device id whose provisioned ranges to extract. - #[arg(long)] - dev_id: u64, + #[arg(long, value_parser = parse_thin_id)] + dev_id: ThinId, +} + +fn parse_thin_id(s: &str) -> Result { + let raw: u32 = s + .parse() + .map_err(|e: std::num::ParseIntError| e.to_string())?; + ThinId::new(raw).map_err(|e| e.to_string()) } pub struct ThinDump { - bin: RootSafeBin<"thin_dump">, + thin_dump: dump::ThinDump, args: ThinDumpArgs, } impl ThinDump { pub fn new(bin: RootSafeBin<"thin_dump">, args: ThinDumpArgs) -> Self { - Self { bin, args } + Self { + thin_dump: dump::ThinDump::new(bin), + args, + } } } impl IsTool for ThinDump { type Args = ThinDumpArgs; type Output = ThinMappings; - type RunT = io::Result; + type RunT = Result; fn run_privileged(&self) -> Self::RunT { + let meta: &SafePath = self.args.meta.as_ref().try_into()?; + let device = SafeFile::::open(meta, OpenFlags::RDONLY)?; // --metadata-snap reads the caller-reserved metadata snapshot, the // only safe way to dump a live pool's metadata device. - self.bin - .command() - .arg("--metadata-snap") - .arg(self.args.meta.as_ref()) - .env_clear() - .output() + Ok(self.thin_dump.dump(&device, self.args.dev_id)?) } fn parse(&self, res: Self::RunT) -> Result> { - let out = res.map_err(ToolError::Spawn)?; - if !out.status.success() { - return Err( - ToolError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()).into(), - ); - } - - let xml = String::from_utf8_lossy(&out.stdout); - Ok(parse_mappings(&xml, self.args.dev_id).map_err(ToolError::from)?) + Ok(res?.parse()?.mappings(self.args.dev_id)?) } } diff --git a/native/suidhelper/src/util/linux/mod.rs b/native/suidhelper/src/util/linux/mod.rs deleted file mode 100644 index e50ac45a..00000000 --- a/native/suidhelper/src/util/linux/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Parsers for Linux storage-tooling output formats. - -pub mod thin_dump; diff --git a/native/suidhelper/src/util/linux/thin_dump.rs b/native/suidhelper/src/util/linux/thin_dump.rs deleted file mode 100644 index f7e4b1cb..00000000 --- a/native/suidhelper/src/util/linux/thin_dump.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! The Linux thin-provisioning-tools `thin_dump` XML document model, -//! deserialized whole via serde — sibling-device isolation and child ordering -//! are structural properties of the model rather than hand-maintained scan -//! state. Validation (the refusals) sits on top of the typed document. - -use serde::Deserialize; -use thiserror::Error as ThisError; - -#[derive(Debug, ThisError)] -pub enum Error { - #[error("thin_dump output has no root")] - NoSuperblock, - #[error("thin device {0} not present in the metadata dump")] - DeviceNotFound(u64), - #[error( - "device mappings incomplete: expected {expected} blocks, parsed {got} (truncated dump?)" - )] - Truncated { expected: u64, got: u64 }, - #[error("thin_dump output is not valid metadata XML: {0}")] - Xml(String), -} - -/// A thin device's provisioned map: pool block size (512-byte sectors) and -/// `(origin_begin, length)` ranges in pool blocks. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct ThinMappings { - pub block_sectors: u64, - pub ranges: Vec<(u64, u64)>, -} - -#[derive(Debug, Deserialize)] -struct Superblock { - #[serde(rename = "@data_block_size")] - data_block_size: u64, - #[serde(rename = "device", default)] - devices: Vec, -} - -#[derive(Debug, Deserialize)] -struct Device { - #[serde(rename = "@dev_id")] - dev_id: u64, - #[serde(rename = "@mapped_blocks")] - mapped_blocks: u64, - // `$value` collects the mixed range/single children in document order; a - // self-closing empty device deserializes to the default empty list. - #[serde(rename = "$value", default)] - mappings: Vec, -} - -#[derive(Debug, Deserialize)] -enum Mapping { - #[serde(rename = "range_mapping")] - Range { - #[serde(rename = "@origin_begin")] - origin_begin: u64, - #[serde(rename = "@length")] - length: u64, - }, - #[serde(rename = "single_mapping")] - Single { - #[serde(rename = "@origin_block")] - origin_block: u64, - }, -} - -impl Mapping { - fn range(&self) -> (u64, u64) { - match *self { - Mapping::Range { - origin_begin, - length, - } => (origin_begin, length), - Mapping::Single { origin_block } => (origin_block, 1), - } - } -} - -/// Parse `thin_dump` XML, extracting `dev_id`'s provisioned ranges. An absent -/// device is an error — an empty result must always mean "device exists, has -/// no provisioned blocks", never "we looked at the wrong dump". -pub fn parse_mappings(xml: &str, dev_id: u64) -> Result { - ensure_superblock_root(xml)?; - - let superblock: Superblock = - quick_xml::de::from_str(xml).map_err(|err| Error::Xml(err.to_string()))?; - - let device = superblock - .devices - .iter() - .find(|device| device.dev_id == dev_id) - .ok_or(Error::DeviceNotFound(dev_id))?; - - let ranges: Vec<(u64, u64)> = device.mappings.iter().map(Mapping::range).collect(); - - let got: u64 = ranges.iter().map(|&(_, len)| len).sum(); - if got != device.mapped_blocks { - return Err(Error::Truncated { - expected: device.mapped_blocks, - got, - }); - } - - Ok(ThinMappings { - block_sectors: superblock.data_block_size, - ranges, - }) -} - -// serde deserializes whatever root element exists into `Superblock` without -// checking its NAME — a dump whose root is not must refuse as -// NoSuperblock, not as a missing-field soup. One event of lookahead pins it. -fn ensure_superblock_root(xml: &str) -> Result<(), Error> { - use quick_xml::events::Event; - - let mut reader = quick_xml::Reader::from_str(xml); - loop { - match reader - .read_event() - .map_err(|err| Error::Xml(err.to_string()))? - { - Event::Start(e) | Event::Empty(e) => { - return if e.name().as_ref() == b"superblock" { - Ok(()) - } else { - Err(Error::NoSuperblock) - }; - } - Event::Eof => return Err(Error::NoSuperblock), - _ => {} - } - } -} diff --git a/native/suidhelper/src/util/mod.rs b/native/suidhelper/src/util/mod.rs index 3f1cf023..8f125da4 100644 --- a/native/suidhelper/src/util/mod.rs +++ b/native/suidhelper/src/util/mod.rs @@ -2,6 +2,5 @@ pub mod chroot_jail; pub mod jailer; -pub mod linux; pub mod safe_dev; pub mod setuid_privileged; diff --git a/native/suidhelper/tests/e2e/thin_dump.rs b/native/suidhelper/tests/e2e/thin_dump.rs index 9b4be490..21147488 100644 --- a/native/suidhelper/tests/e2e/thin_dump.rs +++ b/native/suidhelper/tests/e2e/thin_dump.rs @@ -32,6 +32,8 @@ printf '%s' "$( for a in "$@"; do printf '%s"%s"' "$sep" "$a"; sep=','; done printf ']' )" > '{record}' +for last in "$@"; do :; done +readlink "$last" > '{record}.target' printf '%s\n' '{XML}' "#, record = record.display(), @@ -91,7 +93,16 @@ fn thin_dump_argv_and_ranges_json_as_root() { ) .success(); - assert_eq!(recorded_argv(&rec), vec!["--metadata-snap", "/dev/loop3"]); + let argv = recorded_argv(&rec); + assert_eq!(argv[..3], ["--metadata-snap", "--dev-id", "7"], "{argv:?}"); + assert!(argv[3].starts_with("/proc/self/fd/"), "{argv:?}"); + assert_eq!( + fs::read_to_string(rec.with_extension("json.target")) + .unwrap() + .trim(), + "/dev/loop3", + "thin_dump must be handed the validated metadata device", + ); let json = stdout_json(&out); assert_eq!(json["block_sectors"], 128); diff --git a/native/suidhelper/tests/util/linux/thin_dump.rs b/native/suidhelper/tests/util/linux/thin_dump.rs deleted file mode 100644 index f55e27ba..00000000 --- a/native/suidhelper/tests/util/linux/thin_dump.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Laws of the thin_dump mappings parser: -//! * oracle: rendering a known device map to thin_dump's XML shape and -//! parsing it back yields exactly the target device's ranges (singles as -//! length-1) and the superblock's data_block_size; -//! * scoping: mappings of OTHER devices in the same dump never leak into -//! the target's ranges (kills a lost `` transition); -//! * refusal: a dump without the target dev_id is an error, never an empty -//! success (an empty result must mean "device exists, no writes"); -//! * a missing superblock is an error; -//! * integrity: the parsed ranges' total length must equal the target -//! device's own `mapped_blocks` attribute, so a dump truncated mid-device -//! is an error, never a silently short success; -//! * anchoring: attribute lookup is by exact XML attribute name, so a key -//! can never match as the suffix of another attribute's name (e.g. -//! `dev_id` inside `snap_dev_id`); -//! * grammar-level: parsing is via a real XML reader (quick-xml), not a -//! fixed line layout, so attribute order, whitespace, and entity escaping -//! never change the result, and malformed XML is a parse error rather -//! than a silently wrong guess. - -use hyper_suidhelper::util::linux::thin_dump::{parse_mappings, Error, ThinMappings}; -use proptest::prelude::*; - -#[derive(Debug, Clone)] -enum M { - Range { begin: u64, len: u64 }, - Single { block: u64 }, -} - -fn mapping() -> impl Strategy { - prop_oneof![ - (0u64..1 << 20, 1u64..512).prop_map(|(begin, len)| M::Range { begin, len }), - (0u64..1 << 20).prop_map(|block| M::Single { block }), - ] -} - -fn render_device(dev_id: u64, maps: &[M]) -> String { - if maps.is_empty() { - // thin_dump self-closes an empty device. - return format!(" \n"); - } - - let mapped: u64 = maps - .iter() - .map(|m| match m { - M::Range { len, .. } => *len, - M::Single { .. } => 1, - }) - .sum(); - - let mut s = format!(" \n"); - for m in maps { - match m { - M::Range { begin, len } => s.push_str(&format!( - " \n" - )), - M::Single { block } => s.push_str(&format!( - " \n" - )), - } - } - s.push_str(" \n"); - s -} - -fn render(block_sectors: u64, devices: &[(u64, Vec)]) -> String { - let mut s = format!( - "\n" - ); - for (id, maps) in devices { - s.push_str(&render_device(*id, maps)); - } - s.push_str("\n"); - s -} - -fn expected_ranges(maps: &[M]) -> Vec<(u64, u64)> { - maps.iter() - .map(|m| match m { - M::Range { begin, len } => (*begin, *len), - M::Single { block } => (*block, 1), - }) - .collect() -} - -proptest! { - #[test] - fn extracts_exactly_the_target_devices_ranges( - block_sectors in 1u64..4096, - target_maps in proptest::collection::vec(mapping(), 0..64), - sibling_maps in proptest::collection::vec(mapping(), 0..64), - ) { - // Sibling before AND after the target: leaked scoping in either - // direction corrupts the result. - let xml = render(block_sectors, &[(1, sibling_maps.clone()), (3, target_maps.clone()), (5, sibling_maps.clone())]); - let got = parse_mappings(&xml, 3).unwrap(); - - prop_assert_eq!(got.block_sectors, block_sectors); - prop_assert_eq!(got.ranges, expected_ranges(&target_maps)); - } - - #[test] - fn absent_device_is_an_error_not_empty( - maps in proptest::collection::vec(mapping(), 0..16), - ) { - let xml = render(128, &[(1, maps)]); - prop_assert!(matches!(parse_mappings(&xml, 99), Err(Error::DeviceNotFound(99)))); - } - - #[test] - fn short_mappings_against_the_claimed_count_are_an_error( - block_sectors in 1u64..4096, - target_maps in proptest::collection::vec(mapping(), 0..64), - inflate in 1u64..64, - ) { - // Well-formed XML whose device CLAIMS more mapped blocks than its - // mapping children carry — the truncation cross-check must refuse. - let mut xml = render(block_sectors, &[(3, target_maps.clone())]); - let claimed: u64 = - expected_ranges(&target_maps).iter().map(|&(_, len)| len).sum::() + inflate; - xml = xml.replace( - &format!("mapped_blocks=\"{}\"", claimed - inflate), - &format!("mapped_blocks=\"{claimed}\""), - ); - let is_truncated = matches!(parse_mappings(&xml, 3), Err(Error::Truncated { .. })); - prop_assert!(is_truncated); - } -} - -#[test] -fn missing_superblock_is_an_error() { - assert!(matches!( - parse_mappings("", 3), - Err(Error::NoSuperblock) - )); -} - -#[test] -fn empty_self_closing_device_parses_to_no_ranges() { - let xml = render(128, &[(3, vec![])]); - assert_eq!( - parse_mappings(&xml, 3).unwrap(), - ThinMappings { - block_sectors: 128, - ranges: vec![] - } - ); -} - -#[test] -fn attribute_lookup_is_anchored_not_suffix_matched() { - // `snap_dev_id` is a decoy: an unanchored lookup for `dev_id` would match - // inside it, read 9, and report the real device 3 as missing. - let xml = "\n \n \n \n\n"; - assert_eq!(parse_mappings(xml, 3).unwrap().ranges, vec![(0, 1)]); -} - -#[test] -fn layout_variance_is_irrelevant() { - // Same document as a single line with shuffled attribute order and an - // escaped uuid — a layout the old line-scanner could not survive. - let xml = ""; - assert_eq!( - parse_mappings(xml, 3).unwrap(), - ThinMappings { - block_sectors: 128, - ranges: vec![(0, 8), (42, 1)] - } - ); -} - -#[test] -fn malformed_xml_is_an_error_not_a_guess() { - assert!(parse_mappings( - " Date: Sun, 25 Oct 2026 21:16:13 -0700 Subject: [PATCH 06/17] new linux/cgroup utilities --- native/suidhelper/Cargo.toml | 2 +- native/suidhelper/src/linux/cgroup/events.rs | 78 ++++++++++ native/suidhelper/src/linux/cgroup/mod.rs | 145 ++++++++++++++++++ native/suidhelper/src/linux/mod.rs | 1 + .../src/tools/chroot_jail/remove.rs | 88 ++--------- .../tests/tools/chroot_jail_remove.rs | 60 -------- 6 files changed, 240 insertions(+), 134 deletions(-) create mode 100644 native/suidhelper/src/linux/cgroup/events.rs create mode 100644 native/suidhelper/src/linux/cgroup/mod.rs diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 4a0fb703..cb96da28 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -137,7 +137,7 @@ bon = "3.9.3" clap = { version = "4", features = ["derive"] } derive_more = { version = "2.1.1", features = ["display", "as_ref", "into", "add", "debug"] } hyper-suidhelper-meta = { path = "meta" } -nix = { version = "0.29", features = ["user", "fs", "dir", "process", "ioctl"] } +nix = { version = "0.29", features = ["user", "fs", "dir", "process", "ioctl", "poll"] } quick-xml = { version = "0.37", features = ["serialize"] } ref-cast = "1.0.25" serde = { version = "1", features = ["derive"] } diff --git a/native/suidhelper/src/linux/cgroup/events.rs b/native/suidhelper/src/linux/cgroup/events.rs new file mode 100644 index 00000000..2061c935 --- /dev/null +++ b/native/suidhelper/src/linux/cgroup/events.rs @@ -0,0 +1,78 @@ +use super::Cgroup; +use crate::fs::file::{Any, IsRegularFile, SafeFile}; +use nix::errno::Errno; +use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; +use nix::unistd::{lseek, read, Whence}; +use std::marker::PhantomData; +use std::os::unix::io::{AsFd, AsRawFd}; +use std::str::Utf8Error; +use std::time::Duration; +use thiserror::Error as ThisError; + +#[derive(Debug, ThisError)] +pub enum ReadError { + #[error("cgroup was removed")] + Removed, + #[error("reading cgroup.events: {0}")] + Read(#[source] Errno), + #[error("cgroup.events is not UTF-8: {0}")] + Utf8(#[source] Utf8Error), + #[error("unexpected cgroup.events contents: {0:?}")] + Malformed(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventsState { + pub populated: bool, + pub frozen: bool, +} + +pub struct Events<'a> { + file: SafeFile, + cgroup: PhantomData<&'a Cgroup>, +} + +impl Events<'_> { + pub(super) fn new(file: SafeFile) -> Self { + Self { + file, + cgroup: PhantomData, + } + } + + pub fn read(&self) -> Result { + let mut buf = [0u8; 256]; + let n = match lseek(self.file.as_raw_fd(), 0, Whence::SeekSet) + .and_then(|_| read(self.file.as_raw_fd(), &mut buf)) + { + Ok(n) => n, + Err(Errno::ENODEV) => return Err(ReadError::Removed), + Err(e) => return Err(ReadError::Read(e)), + }; + let text = std::str::from_utf8(&buf[..n]).map_err(ReadError::Utf8)?; + let flag = |key: &str| { + text.lines() + .find_map(|line| line.strip_prefix(key)?.strip_prefix(' ')) + .and_then(|value| match value { + "0" => Some(false), + "1" => Some(true), + _ => None, + }) + }; + match (flag("populated"), flag("frozen")) { + (Some(populated), Some(frozen)) => Ok(EventsState { populated, frozen }), + _ => Err(ReadError::Malformed(text.to_string())), + } + } + + pub fn wait(&self, timeout: Duration) -> Result<(), Errno> { + let timeout = PollTimeout::try_from(timeout).unwrap_or(PollTimeout::MAX); + match poll( + &mut [PollFd::new(self.file.as_fd(), PollFlags::POLLPRI)], + timeout, + ) { + Ok(_) | Err(Errno::EINTR) => Ok(()), + Err(e) => Err(e), + } + } +} diff --git a/native/suidhelper/src/linux/cgroup/mod.rs b/native/suidhelper/src/linux/cgroup/mod.rs new file mode 100644 index 00000000..7097dc6d --- /dev/null +++ b/native/suidhelper/src/linux/cgroup/mod.rs @@ -0,0 +1,145 @@ +mod events; + +pub use events::{Events, EventsState, ReadError}; + +use crate::fs::dir::SafeDir; +use crate::fs::file::{self, Any, IsDirectory, OpenFlags, SafeFile}; +use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use nix::errno::Errno; +use nix::sys::stat::Mode; +use nix::sys::statfs::{fstatfs, CGROUP2_SUPER_MAGIC}; +use std::ffi::OsString; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use thiserror::Error as ThisError; + +#[derive(Debug, ThisError)] +pub enum OpenError { + #[error("cgroup path has no parent: {0:?}")] + NoParent(PathBuf), + #[error("cgroup parent path: {0}")] + Path(#[from] path::ValidationError), + #[error(transparent)] + Open(#[from] file::ValidationError), + #[error("statfs on cgroup: {0}")] + Statfs(#[source] Errno), + #[error("{0:?} is not on a cgroup2 filesystem")] + NotCgroup(PathBuf), +} + +#[derive(Debug, ThisError)] +pub enum KillError { + #[error("this kernel has no cgroup.kill (needs Linux 5.14+)")] + Unsupported, + #[error("opening cgroup.kill: {0}")] + Open(#[source] file::ValidationError), + #[error("writing cgroup.kill: {0}")] + Write(#[source] io::Error), + #[error("opening cgroup.events: {0}")] + Events(#[source] file::ValidationError), + #[error(transparent)] + Read(#[from] ReadError), + #[error("waiting on cgroup.events: {0}")] + Wait(#[source] Errno), + #[error("cgroup still has processes {0:?} after SIGKILL")] + TimedOut(Duration), +} + +pub struct Cgroup { + parent: SafeDir, + name: OsString, +} + +impl Cgroup { + /// Open a handle to this cgroup. + pub fn open(path: &SafePath) -> Result { + let full: &Path = path.as_ref(); + let (Some(parent), Some(name)) = (full.parent(), full.file_name()) else { + return Err(OpenError::NoParent(full.to_path_buf())); + }; + + let parent: &SafePath = parent.try_into()?; + let parent = SafeDir::open(parent, OpenFlags::RDONLY)?; + + let leaf: SafeDir = parent.openat(Path::new(name), OpenFlags::RDONLY, Mode::empty())?; + if fstatfs(&leaf).map_err(OpenError::Statfs)?.filesystem_type() != CGROUP2_SUPER_MAGIC { + return Err(OpenError::NotCgroup(full.to_path_buf())); + } + + Ok(Self { + parent, + name: name.to_os_string(), + }) + } + + /// Kill all processes under this cgroup. + pub fn kill_procs(&self, timeout: Duration) -> Result<(), KillError> { + let deadline = Instant::now() + timeout; + + let events = match self.events() { + Ok(events) => events, + Err(file::ValidationError::Open(Errno::ENOENT | Errno::ENODEV)) => return Ok(()), + Err(e) => return Err(KillError::Events(e)), + }; + + let kill_path = Path::new(&self.name).join("cgroup.kill"); + let mut kill: SafeFile = + match self + .parent + .openat(&kill_path, OpenFlags::WRONLY, Mode::empty()) + { + Ok(kill) => kill, + Err(file::ValidationError::Open(Errno::ENODEV)) => return Ok(()), + Err(file::ValidationError::Open(Errno::ENOENT)) => { + return match self.parent.openat::( + Path::new(&self.name), + OpenFlags::PATH, + Mode::empty(), + ) { + Err(file::ValidationError::Open(Errno::ENOENT)) => Ok(()), + _ => Err(KillError::Unsupported), + }; + } + Err(e) => return Err(KillError::Open(e)), + }; + + match kill.write_all(b"1") { + Ok(()) => {} + Err(e) if e.raw_os_error().map(Errno::from_raw) == Some(Errno::ENODEV) => return Ok(()), + Err(e) => return Err(KillError::Write(e)), + } + + loop { + match events.read() { + Ok(state) if !state.populated => return Ok(()), + Ok(_) => {} + Err(ReadError::Removed) => return Ok(()), + Err(e) => return Err(e.into()), + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(KillError::TimedOut(timeout)); + } + events.wait(remaining).map_err(KillError::Wait)?; + } + } + + /// Fetch a handle to the events of this cgroup. + pub fn events(&self) -> Result, file::ValidationError> { + let file = self.parent.openat( + &Path::new(&self.name).join("cgroup.events"), + OpenFlags::RDONLY, + Mode::empty(), + )?; + Ok(Events::new(file)) + } + + /// Remove the cgroup. + pub fn remove(self) -> Result<(), Errno> { + match self.parent.rmdirat(Path::new(&self.name)) { + Ok(()) | Err(Errno::ENOENT) => Ok(()), + Err(e) => Err(e), + } + } +} diff --git a/native/suidhelper/src/linux/mod.rs b/native/suidhelper/src/linux/mod.rs index 04b17d5b..908f5eec 100644 --- a/native/suidhelper/src/linux/mod.rs +++ b/native/suidhelper/src/linux/mod.rs @@ -1,3 +1,4 @@ //! Linux-specific utilities +pub mod cgroup; pub mod dm; diff --git a/native/suidhelper/src/tools/chroot_jail/remove.rs b/native/suidhelper/src/tools/chroot_jail/remove.rs index 2094c603..d85b3296 100644 --- a/native/suidhelper/src/tools/chroot_jail/remove.rs +++ b/native/suidhelper/src/tools/chroot_jail/remove.rs @@ -15,15 +15,14 @@ use crate::config::Config; use crate::fs::dir::{self, SafeDir}; -use crate::fs::file::{self, Any, OpenFlags, SafeFile}; +use crate::fs::file::{self, OpenFlags}; use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::linux::cgroup::{self, Cgroup}; use crate::tools::IsTool; use clap::Args; use nix::errno::Errno; use nix::sys::stat::Mode; use serde::Serialize; -use std::ffi::OsStr; -use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use thiserror::Error as ThisError; @@ -31,11 +30,7 @@ use thiserror::Error as ThisError; /// The cgroup virtual filesystem root. const CGROUP_BASE: &str = "/sys/fs/cgroup"; -/// How many times to retry the leaf `rmdir` while a killed cgroup drains, and the -/// pause between tries: ~`ATTEMPTS * BACKOFF` total (a cgroup reaps in a few ms), -/// after which a still-busy leaf is left for a later sweep rather than failing. -const RMDIR_ATTEMPTS: u32 = 20; -const RMDIR_BACKOFF_MS: u64 = 5; +const KILL_TIMEOUT: Duration = Duration::from_secs(5); type LexicalPath = SafePath; @@ -59,10 +54,10 @@ pub enum Error { RemoveChroot(#[source] dir::Error), #[error("removing cgroup: {0}")] RemoveCgroup(#[source] Errno), - #[error("opening cgroup.kill: {0}")] - OpenCgroupKill(#[source] file::ValidationError), + #[error("opening cgroup: {0}")] + OpenCgroup(#[source] cgroup::OpenError), #[error("killing cgroup procs: {0}")] - KillCgroup(#[source] io::Error), + KillCgroup(#[source] cgroup::KillError), } #[derive(Args)] @@ -137,72 +132,19 @@ pub fn remove_chroot_under(jail_base: &Path, chroot: &Path) -> Result<(), Error> pub fn remove_cgroup_under(base: &Path, cgroup: &Path) -> Result<(), Error> { let path: &LexicalPath = cgroup.try_into().map_err(Error::CgroupPath)?; let rel = path.strip_base(base).map_err(Error::CgroupBase)?; - let segments: Vec<&OsStr> = rel.segments().collect(); - let Some((leaf, parents)) = segments.split_last().filter(|(_, p)| !p.is_empty()) else { + if rel.segments().count() < 2 { return Err(Error::CgroupDepth(cgroup.to_path_buf())); - }; - let leaf = Path::new(leaf); - - let Some(parent) = walk(base, &parents.iter().collect::())? else { - return Ok(()); // an ancestor is already gone - }; + } // Kill any process still in the leaf cgroup before removing it. Open the leaf // O_NOFOLLOW (one step past the parent walk) and write "1" to cgroup.kill. - match parent.openat(leaf, OpenFlags::RDONLY, Mode::empty()) { - Ok(leaf_dir) => kill_cgroup(&leaf_dir)?, - Err(file::ValidationError::Open(Errno::ENOENT)) => return Ok(()), // leaf already gone - Err(e) => return Err(Error::Walk(e)), - } - - rmdir_drained(&parent, leaf) -} - -/// `rmdir` the killed cgroup leaf, retrying while it drains. `cgroup.kill` signals -/// SIGKILL synchronously but the kernel reaps the processes and offlines the -/// cgroup asynchronously, so the `rmdir` briefly returns `EBUSY`/`ENOTEMPTY`. -/// Retry a bounded number of times; if it still has not drained, the processes are -/// already dead (the kill is the load-bearing op) and a later sweep or the next -/// relaunch clears the empty leaf, so a persistent busy is success — never fail -/// the caller (a relaunch) over leftover-dir cleanup. `ENOENT` means already gone. -fn rmdir_drained(parent: &SafeDir, leaf: &Path) -> Result<(), Error> { - for attempt in 0..RMDIR_ATTEMPTS { - match parent.rmdirat(leaf) { - Ok(()) | Err(Errno::ENOENT) => return Ok(()), - Err(Errno::EBUSY | Errno::ENOTEMPTY) => { - if attempt + 1 < RMDIR_ATTEMPTS { - std::thread::sleep(Duration::from_millis(RMDIR_BACKOFF_MS)); - } - } - Err(e) => return Err(Error::RemoveCgroup(e)), - } - } - Ok(()) -} - -/// Best-effort SIGKILL of every process in the v2 cgroup `leaf_dir`: write "1" to -/// its `cgroup.kill` pseudo-file. A missing file (`ENOENT`: pre-5.14/non-v2 -/// kernel, or already-emptied leaf) or a cgroup torn down concurrently (`ENODEV`) -/// is tolerated — killing must not hard-fail the remove. -fn kill_cgroup(leaf_dir: &SafeDir) -> Result<(), Error> { - let mut kill: SafeFile = - match leaf_dir.openat(Path::new("cgroup.kill"), OpenFlags::WRONLY, Mode::empty()) { - Ok(kill) => kill, - Err(file::ValidationError::Open(Errno::ENOENT | Errno::ENODEV)) => return Ok(()), - Err(e) => return Err(Error::OpenCgroupKill(e)), - }; - match kill.write_all(b"1") { - Ok(()) => Ok(()), - Err(e) - if matches!( - e.raw_os_error().map(Errno::from_raw), - Some(Errno::ENOENT | Errno::ENODEV) - ) => - { - Ok(()) - } - Err(e) => Err(Error::KillCgroup(e)), - } + let cgroup = match Cgroup::open(path) { + Ok(cgroup) => cgroup, + Err(cgroup::OpenError::Open(file::ValidationError::Open(Errno::ENOENT))) => return Ok(()), + Err(e) => return Err(Error::OpenCgroup(e)), + }; + cgroup.kill_procs(KILL_TIMEOUT).map_err(Error::KillCgroup)?; + cgroup.remove().map_err(Error::RemoveCgroup) } /// Open `base` and walk `parents` from it (`O_NOFOLLOW` each step). Returns diff --git a/native/suidhelper/tests/tools/chroot_jail_remove.rs b/native/suidhelper/tests/tools/chroot_jail_remove.rs index 86be181c..86f946c4 100644 --- a/native/suidhelper/tests/tools/chroot_jail_remove.rs +++ b/native/suidhelper/tests/tools/chroot_jail_remove.rs @@ -94,19 +94,6 @@ fn missing_cgroup_is_ok() { remove_cgroup_under(base, &cgroup).expect("missing cgroup must be Ok"); } -// A non-empty cgroup leaf yields ENOTEMPTY, which the op treats as success -// (something else is still using it; not our job to force-empty it). -#[test] -fn nonempty_cgroup_leaf_is_ok() { - let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path(); - let leaf = base.join("slice").join("leaf"); - fs::create_dir_all(&leaf).unwrap(); - fs::create_dir(leaf.join("child")).unwrap(); // makes rmdir return ENOTEMPTY - remove_cgroup_under(base, &leaf).expect("non-empty leaf must be Ok (ENOTEMPTY)"); - assert!(leaf.exists(), "non-empty leaf must survive"); -} - // A real / tree with contents is fully removed; a sibling id survives. #[test] fn removes_chroot_tree_and_spares_siblings() { @@ -123,20 +110,6 @@ fn removes_chroot_tree_and_spares_siblings() { assert!(sibling.exists(), "sibling chroot must survive"); } -// A real cgroup leaf is removed; its parent survives. -#[test] -fn removes_cgroup_leaf_and_spares_parent() { - let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path(); - let parent = base.join("slice"); - let leaf = parent.join("leaf"); - fs::create_dir_all(&leaf).unwrap(); - - remove_cgroup_under(base, &leaf).unwrap(); - assert!(!leaf.exists(), "cgroup leaf must be gone"); - assert!(parent.exists(), "cgroup parent must survive"); -} - // A symlinked component must NOT be followed: removal must fail (or no-op) // without deleting through the symlink. A sentinel outside the jail must survive. #[test] @@ -164,39 +137,6 @@ fn symlinked_chroot_component_does_not_escape() { ); } -// cgroup.kill is written "1" before the rmdir: the fix SIGKILLs the subtree -// first. Here cgroup.kill keeps the leaf non-empty so rmdir returns ENOTEMPTY -// (tolerated) and the leaf survives — letting us assert the write happened. -#[test] -fn cgroup_kill_is_written_before_rmdir() { - let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path(); - let leaf = base.join("slice").join("leaf"); - fs::create_dir_all(&leaf).unwrap(); - fs::write(leaf.join("cgroup.kill"), b"0").unwrap(); - - remove_cgroup_under(base, &leaf).expect("write+rmdir must be Ok"); - assert!(leaf.exists(), "non-empty leaf must survive"); - assert_eq!( - fs::read(leaf.join("cgroup.kill")).unwrap(), - b"1", - "cgroup.kill must have been written \"1\"", - ); -} - -// A leaf without a cgroup.kill file (pre-5.14/non-v2 kernel) is tolerated: the -// missing pseudo-file is a no-op and the empty leaf is removed normally. -#[test] -fn missing_cgroup_kill_is_tolerated() { - let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path(); - let leaf = base.join("slice").join("leaf"); - fs::create_dir_all(&leaf).unwrap(); - - remove_cgroup_under(base, &leaf).expect("missing cgroup.kill must be Ok"); - assert!(!leaf.exists(), "empty leaf must be removed"); -} - // The leaf is already gone but its parent survives: the new openat_dir(leaf) // ENOENT branch returns Ok without touching the parent. #[test] From 1bfd7ef23f32b4affc81edb53e21330858d3bf5f Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 25 Oct 2026 22:26:17 -0700 Subject: [PATCH 07/17] more refactors --- lib/hyper/node/img/publish.ex | 9 +- lib/hyper/node/img/server.ex | 71 +++-- lib/hyper/node/img/thin_pool.ex | 18 +- lib/hyper/node/layer/server.ex | 49 +-- lib/hyper/node/reclaim.ex | 29 +- lib/hyper/suid_helper/dmsetup.ex | 52 ++-- lib/hyper/suid_helper/losetup.ex | 64 ---- native/suidhelper/Cargo.toml | 8 +- native/suidhelper/src/config.rs | 7 - native/suidhelper/src/fs/dev.rs | 41 +++ native/suidhelper/src/fs/dir.rs | 96 +++--- native/suidhelper/src/fs/file.rs | 36 ++- native/suidhelper/src/fs/mod.rs | 1 + native/suidhelper/src/fs/path.rs | 168 ++++++++++- native/suidhelper/src/linux/cgroup/mod.rs | 37 +-- native/suidhelper/src/linux/dm/mod.rs | 9 +- native/suidhelper/src/linux/dm/table.rs | 8 + native/suidhelper/src/linux/dm/thin.rs | 3 +- native/suidhelper/src/linux/loopdev.rs | 196 ++++++++++++ native/suidhelper/src/linux/mod.rs | 7 + .../suidhelper/src/tools/chroot_jail/grant.rs | 26 +- .../src/tools/chroot_jail/grant_api.rs | 25 +- .../src/tools/chroot_jail/grant_vsock.rs | 25 +- .../src/tools/chroot_jail/remove.rs | 97 +++--- .../suidhelper/src/tools/dmsetup/backing.rs | 47 +++ native/suidhelper/src/tools/dmsetup/mod.rs | 44 ++- native/suidhelper/src/tools/dmsetup/table.rs | 278 ++++++++++++++++-- native/suidhelper/src/tools/losetup.rs | 171 ----------- native/suidhelper/src/tools/mod.rs | 14 +- native/suidhelper/src/util/chroot_jail.rs | 75 ++--- native/suidhelper/tests/config_seam.rs | 7 +- native/suidhelper/tests/e2e/argv.rs | 76 ++--- native/suidhelper/tests/e2e/losetup.rs | 228 -------------- .../tests/tools/chroot_jail_grant_api.rs | 7 +- .../tests/tools/chroot_jail_grant_vsock.rs | 7 +- .../tests/tools/chroot_jail_remove.rs | 21 +- .../suidhelper/tests/tools/dmsetup_backing.rs | 99 +++++++ .../suidhelper/tests/tools/dmsetup_parsers.rs | 62 ++-- .../suidhelper/tests/tools/losetup_backing.rs | 170 ----------- native/suidhelper/tests/util/confinement.rs | 17 +- .../tests/util/safe_path.proptest-regressions | 7 + native/suidhelper/tests/util/safe_path.rs | 7 +- test/hyper/suid_helper/losetup_test.exs | 37 --- 43 files changed, 1286 insertions(+), 1170 deletions(-) delete mode 100644 lib/hyper/suid_helper/losetup.ex create mode 100644 native/suidhelper/src/fs/dev.rs create mode 100644 native/suidhelper/src/linux/loopdev.rs create mode 100644 native/suidhelper/src/tools/dmsetup/backing.rs delete mode 100644 native/suidhelper/src/tools/losetup.rs delete mode 100644 native/suidhelper/tests/e2e/losetup.rs create mode 100644 native/suidhelper/tests/tools/dmsetup_backing.rs delete mode 100644 native/suidhelper/tests/tools/losetup_backing.rs create mode 100644 native/suidhelper/tests/util/safe_path.proptest-regressions delete mode 100644 test/hyper/suid_helper/losetup_test.exs diff --git a/lib/hyper/node/img/publish.ex b/lib/hyper/node/img/publish.ex index 85450f44..82c831b4 100644 --- a/lib/hyper/node/img/publish.ex +++ b/lib/hyper/node/img/publish.ex @@ -85,21 +85,18 @@ defmodule Hyper.Node.Img.Publish do defp write_delta(tmp, ro_dev, {snap_dev, snap_id}, sectors, parent_img_id) do cow_path = Path.join(Hyper.Cfg.Dirs.scratch_dir(), "fork-delta-#{tmp}.img") - with :ok <- create_sparse(cow_path, cow_size(sectors)), - {:ok, cow_loop} <- SuidHelper.Losetup.attach_rw(cow_path) do + with :ok <- create_sparse(cow_path, cow_size(sectors)) do try do with {:ok, write_dev} <- - SuidHelper.Dmsetup.create_snapshot_rw(cow_name(tmp), ro_dev, cow_loop, sectors), + SuidHelper.Dmsetup.create_snapshot_rw(cow_name(tmp), ro_dev, cow_path, sectors), {:ok, _stats} <- copy_divergence(snap_dev, snap_id, write_dev), # Removing the snapshot device flushes every exception to the store; # only then is the COW file complete on disk. - :ok <- SuidHelper.Dmsetup.remove(cow_name(tmp)), - :ok <- SuidHelper.Losetup.detach(cow_loop) do + :ok <- SuidHelper.Dmsetup.remove(cow_name(tmp)) do Hyper.Img.create_derived(parent_img_id, cow_path, label: "fork of #{parent_img_id}") else {:error, _} = err -> _ = SuidHelper.Dmsetup.remove(cow_name(tmp)) - _ = SuidHelper.Losetup.detach(cow_loop) err end after diff --git a/lib/hyper/node/img/server.ex b/lib/hyper/node/img/server.ex index bcdd5f83..98f2c687 100644 --- a/lib/hyper/node/img/server.ex +++ b/lib/hyper/node/img/server.ex @@ -22,6 +22,7 @@ defmodule Hyper.Node.Img.Server do alias Hyper.Img.Db alias Hyper.Node.Layer alias Hyper.SuidHelper + alias Unit.Information use OpenTelemetryDecorator @@ -81,8 +82,8 @@ defmodule Hyper.Node.Img.Server do Process.flag(:trap_exit, true) layer_ids = resolve_layers(img_id) - with {:ok, loop_paths} <- acquire_layers(layer_ids), - {:ok, %{blk_path: blk_path, dm_names: dm_names}} <- build_chain(img_id, loop_paths) do + with {:ok, layer_paths} <- acquire_layers(layer_ids), + {:ok, %{blk_path: blk_path, dm_names: dm_names}} <- build_chain(img_id, layer_paths) do state = %State{img_id: img_id, layers: layer_ids, dm_names: dm_names, blk_path: blk_path} {:ok, arm_idle(state)} else @@ -171,7 +172,7 @@ defmodule Hyper.Node.Img.Server do |> Enum.reduce_while({:ok, []}, fn layer_id, {:ok, paths} -> with {:ok, pid} <- Layer.Server.for_layer(layer_id), :ok <- Layer.Server.acquire(pid) do - {:cont, {:ok, [Layer.Server.blk_path(pid) | paths]}} + {:cont, {:ok, [Layer.Server.path(pid) | paths]}} else {:error, reason} -> {:halt, {:error, reason}} end @@ -193,29 +194,55 @@ defmodule Hyper.Node.Img.Server do defp build_chain(_img_id, []), do: {:error, :unknown_image} defp build_chain(img_id, [base | deltas]) do - with {:ok, sectors} <- SuidHelper.Blockdev.device_sectors(base) do - deltas - |> Enum.with_index(1) - |> Enum.reduce_while({:ok, base, []}, fn {cow_dev, idx}, {:ok, origin, names} -> - name = dm_name(img_id, idx) - - case SuidHelper.Dmsetup.create_snapshot(name, origin, cow_dev, sectors) do - {:ok, dev} -> - {:cont, {:ok, dev, [name | names]}} - - {:error, reason} -> - Enum.each(names, &remove_quietly/1) - {:halt, {:error, reason}} - end - end) - |> case do - {:ok, blk_path, names} -> {:ok, %{blk_path: blk_path, dm_names: Enum.reverse(names)}} - {:error, _} = err -> err + with {:ok, sectors} <- file_sectors(base) do + case deltas do + [] -> build_linear(img_id, base, sectors) + deltas -> build_snapshots(img_id, base, deltas, sectors) end end end - @spec dm_name(Hyper.Img.id(), pos_integer()) :: String.t() + @spec build_linear(Hyper.Img.id(), Path.t(), pos_integer()) :: + {:ok, %{blk_path: Path.t(), dm_names: [String.t()]}} | {:error, term()} + defp build_linear(img_id, base, sectors) do + name = dm_name(img_id, 0) + + with {:ok, dev} <- SuidHelper.Dmsetup.create_linear(name, base, sectors) do + {:ok, %{blk_path: dev, dm_names: [name]}} + end + end + + @spec build_snapshots(Hyper.Img.id(), Path.t(), [Path.t()], pos_integer()) :: + {:ok, %{blk_path: Path.t(), dm_names: [String.t()]}} | {:error, term()} + defp build_snapshots(img_id, base, deltas, sectors) do + deltas + |> Enum.with_index(1) + |> Enum.reduce_while({:ok, base, []}, fn {cow_file, idx}, {:ok, origin, names} -> + name = dm_name(img_id, idx) + + case SuidHelper.Dmsetup.create_snapshot(name, origin, cow_file, sectors) do + {:ok, dev} -> + {:cont, {:ok, dev, [name | names]}} + + {:error, reason} -> + Enum.each(names, &remove_quietly/1) + {:halt, {:error, reason}} + end + end) + |> case do + {:ok, blk_path, names} -> {:ok, %{blk_path: blk_path, dm_names: Enum.reverse(names)}} + {:error, _} = err -> err + end + end + + @spec file_sectors(Path.t()) :: {:ok, non_neg_integer()} | {:error, File.posix()} + defp file_sectors(path) do + with {:ok, %File.Stat{size: size}} <- File.stat(path) do + {:ok, size |> Information.bytes() |> Information.as_sectors()} + end + end + + @spec dm_name(Hyper.Img.id(), non_neg_integer()) :: String.t() defp dm_name(img_id, idx), do: "hyper-img-#{sanitize(img_id)}-#{idx}" @spec sanitize(String.t()) :: String.t() diff --git a/lib/hyper/node/img/thin_pool.ex b/lib/hyper/node/img/thin_pool.ex index 05850c95..c017cb87 100644 --- a/lib/hyper/node/img/thin_pool.ex +++ b/lib/hyper/node/img/thin_pool.ex @@ -25,8 +25,8 @@ defmodule Hyper.Node.Img.ThinPool do defmodule State do @moduledoc false - @enforce_keys [:pool_dev, :meta_loop, :data_loop] - defstruct [:pool_dev, :meta_loop, :data_loop, next: 0, freed: []] + @enforce_keys [:pool_dev, :meta_loop] + defstruct [:pool_dev, :meta_loop, next: 0, freed: []] end @spec start_link(keyword()) :: GenServer.on_start() @@ -94,19 +94,17 @@ defmodule Hyper.Node.Img.ThinPool do {:ok, data} <- ensure_backing(@data_file, ImgConfig.thin_pool_data_size()), :ok <- reclaim_stale_pool(), :ok <- zero_metadata(meta), - {:ok, meta_loop} <- SuidHelper.Losetup.attach_rw(meta), - {:ok, data_loop} <- SuidHelper.Losetup.attach_rw(data), sectors = Information.as_sectors(ImgConfig.thin_pool_data_size()), - {:ok, pool_dev} <- + {:ok, pool_dev, meta_loop} <- SuidHelper.Dmsetup.create_thin_pool( @pool_name, - meta_loop, - data_loop, + meta, + data, sectors, ImgConfig.thin_block_sectors(), 0 ) do - {:ok, %State{pool_dev: pool_dev, meta_loop: meta_loop, data_loop: data_loop}} + {:ok, %State{pool_dev: pool_dev, meta_loop: meta_loop}} else {:error, reason} -> {:stop, reason} end @@ -196,10 +194,8 @@ defmodule Hyper.Node.Img.ThinPool do def handle_info({:EXIT, _pid, reason}, state), do: {:stop, reason, state} @impl true - def terminate(_reason, state) do + def terminate(_reason, _state) do _ = SuidHelper.Dmsetup.remove(@pool_name) - _ = SuidHelper.Losetup.detach(state.data_loop) - _ = SuidHelper.Losetup.detach(state.meta_loop) :ok end diff --git a/lib/hyper/node/layer/server.ex b/lib/hyper/node/layer/server.ex index 6b3332ce..854fcd5d 100644 --- a/lib/hyper/node/layer/server.ex +++ b/lib/hyper/node/layer/server.ex @@ -14,22 +14,20 @@ defmodule Hyper.Node.Layer.Server do # it just tore down. See the reconciliation TODO in `Hyper.Node.Reaper` for why # coupling resource lifetime to process lifetime is a smell. use GenServer, restart: :temporary - require Logger alias Hyper.Node.Layer alias Hyper.Node.Layer.Repo - alias Hyper.SuidHelper defmodule State do @moduledoc false @type t :: %__MODULE__{ - blk_path: Path.t(), + path: Path.t(), holders: %{pid() => reference()}, idle_ref: reference() | nil } - defstruct [:blk_path, holders: %{}, idle_ref: nil] + defstruct [:path, holders: %{}, idle_ref: nil] end defmodule Opts do @@ -62,9 +60,8 @@ defmodule Hyper.Node.Layer.Server do @spec release(GenServer.server()) :: :ok def release(server), do: GenServer.call(server, {:release, self()}) - @doc "Get the block device path of the layer managed by `server`." - @spec blk_path(GenServer.server()) :: Path.t() - def blk_path(server), do: GenServer.call(server, :blk_path) + @spec path(GenServer.server()) :: Path.t() + def path(server), do: GenServer.call(server, :path) @doc "Create a new layer, mounting it as a block device." @spec start_link(Opts.t()) :: GenServer.on_start() @@ -74,13 +71,9 @@ defmodule Hyper.Node.Layer.Server do @impl true def init(%Opts{layer_id: layer_id}) do - Process.flag(:trap_exit, true) - - with {:ok, layer_path} <- Repo.find_layer(layer_id), - {:ok, blk_path} <- SuidHelper.Losetup.attach_ro(layer_path) do + case Repo.find_layer(layer_id) do # Start with no holders, so a mounted-but-unused layer reaps itself. - {:ok, arm_idle(%State{blk_path: blk_path})} - else + {:ok, path} -> {:ok, arm_idle(%State{path: path})} {:error, reason} -> {:stop, reason} end end @@ -103,8 +96,8 @@ defmodule Hyper.Node.Layer.Server do end @impl true - def handle_call(:blk_path, _from, %State{blk_path: blk_path} = state) do - {:reply, blk_path, state} + def handle_call(:path, _from, %State{path: path} = state) do + {:reply, path, state} end @impl true @@ -123,32 +116,6 @@ defmodule Hyper.Node.Layer.Server do {:noreply, state} end - @impl true - # Each privileged command runs through `System.cmd`, which links a transient - # port to this process and returns only once that command has finished. Because - # we trap exits (for `terminate/2` teardown), the now-defunct port's exit lands - # here afterwards -- stale by construction, whatever its reason -- so ignore it. - def handle_info({:EXIT, port, _reason}, state) when is_port(port), do: {:noreply, state} - - @impl true - # No process is deliberately linked here beyond those transient command ports, - # so a linked *process* EXIT is a genuine fault: propagate its reason (so - # terminate/2 still runs teardown) rather than crash opaquely on an unmatched - # message. - def handle_info({:EXIT, _pid, reason}, state), do: {:stop, reason, state} - - @impl true - def terminate(_reason, %State{blk_path: blk_path}) do - case SuidHelper.Losetup.detach(blk_path) do - :ok -> - :ok - - {:error, {errc, out}} -> - Logger.error("Failed to unmount layer block device #{blk_path} (exit #{errc}): #{out}") - :ok - end - end - # Remove `pid` from the holder set (no-op if it isn't one) and re-arm the idle # timer if that was the last holder. @spec drop(State.t(), pid()) :: State.t() diff --git a/lib/hyper/node/reclaim.ex b/lib/hyper/node/reclaim.ex index a816a4fb..51d51ec0 100644 --- a/lib/hyper/node/reclaim.ex +++ b/lib/hyper/node/reclaim.ex @@ -17,7 +17,7 @@ defmodule Hyper.Node.Reclaim do collides with its own previous instance's leftovers. """ - alias Hyper.SuidHelper.{Dmsetup, Losetup} + alias Hyper.SuidHelper.Dmsetup require Logger @@ -26,7 +26,6 @@ defmodule Hyper.Node.Reclaim do @spec run() :: :ok def run do reclaim_dm() - reclaim_loops() reclaim_sockets() :ok end @@ -107,30 +106,4 @@ defmodule Hyper.Node.Reclaim do true -> Logger.error("reclaim: could not remove dm devices: #{inspect(failed)}") end end - - defp reclaim_loops do - case Losetup.list() do - {:ok, pairs} -> - for {dev, backing} <- pairs, under_data_dirs?(backing) do - case Losetup.detach(dev) do - :ok -> - :ok - - {:error, reason} -> - Logger.warning("reclaim: could not detach #{dev} (#{backing}): #{inspect(reason)}") - end - end - - :ok - - {:error, reason} -> - Logger.warning("reclaim: could not list loop devices: #{inspect(reason)}") - end - end - - @spec under_data_dirs?(Path.t()) :: boolean() - defp under_data_dirs?(path) do - String.starts_with?(path, Hyper.Cfg.Dirs.scratch_dir() <> "/") or - String.starts_with?(path, Hyper.Cfg.Dirs.layer_dir() <> "/") - end end diff --git a/lib/hyper/suid_helper/dmsetup.ex b/lib/hyper/suid_helper/dmsetup.ex index 5b11e8a4..b7711677 100644 --- a/lib/hyper/suid_helper/dmsetup.ex +++ b/lib/hyper/suid_helper/dmsetup.ex @@ -7,7 +7,7 @@ defmodule Hyper.SuidHelper.Dmsetup do @type err :: SuidHelper.err() - @required_targets ~w(snapshot thin thin-pool) + @required_targets ~w(linear snapshot thin thin-pool) @doc """ Create a read-only dm-snapshot device named `name`, layering `cow_dev` @@ -17,9 +17,16 @@ defmodule Hyper.SuidHelper.Dmsetup do @spec create_snapshot(String.t(), Path.t(), Path.t(), pos_integer()) :: {:ok, Path.t()} | {:error, err()} @decorate with_span("Hyper.SuidHelper.Dmsetup.create_snapshot", include: [:name]) - def create_snapshot(name, origin_dev, cow_dev, sectors) do - table = snapshot_table(origin_dev, cow_dev, sectors, Hyper.Cfg.Img.chunk_sectors()) - create(name, table, ["--readonly"]) + def create_snapshot(name, origin, cow_file, sectors) do + table = snapshot_table(origin, cow_file, sectors, Hyper.Cfg.Img.chunk_sectors()) + with {:ok, dev, _loops} <- create(name, table, ["--readonly"]), do: {:ok, dev} + end + + @spec create_linear(String.t(), Path.t(), pos_integer()) :: {:ok, Path.t()} | {:error, err()} + @decorate with_span("Hyper.SuidHelper.Dmsetup.create_linear", include: [:name]) + def create_linear(name, origin, sectors) do + with {:ok, dev, _loops} <- create(name, linear_table(origin, sectors), ["--readonly"]), + do: {:ok, dev} end @doc """ @@ -35,11 +42,13 @@ defmodule Hyper.SuidHelper.Dmsetup do pos_integer(), pos_integer(), non_neg_integer() - ) :: {:ok, Path.t()} | {:error, err()} + ) :: {:ok, Path.t(), Path.t()} | {:error, err()} @decorate with_span("Hyper.SuidHelper.Dmsetup.create_thin_pool", include: [:name]) - def create_thin_pool(name, meta_dev, data_dev, sectors, block_sectors, low_water) do - table = thin_pool_table(meta_dev, data_dev, sectors, block_sectors, low_water) - create(name, table, []) + def create_thin_pool(name, meta_file, data_file, sectors, block_sectors, low_water) do + table = thin_pool_table(meta_file, data_file, sectors, block_sectors, low_water) + + with {:ok, dev, %{"metadata" => meta_loop}} <- create(name, table, []), + do: {:ok, dev, meta_loop} end @doc """ @@ -52,7 +61,7 @@ defmodule Hyper.SuidHelper.Dmsetup do @decorate with_span("Hyper.SuidHelper.Dmsetup.create_thin_external", include: [:name]) def create_thin_external(name, pool_dev, dev_id, sectors, origin_dev) do table = thin_external_table(pool_dev, dev_id, sectors, origin_dev) - create(name, table, []) + with {:ok, dev, _loops} <- create(name, table, []), do: {:ok, dev} end @doc "Remove the dm device `name`." @@ -94,9 +103,9 @@ defmodule Hyper.SuidHelper.Dmsetup do @spec create_snapshot_rw(String.t(), Path.t(), Path.t(), pos_integer()) :: {:ok, Path.t()} | {:error, err()} @decorate with_span("Hyper.SuidHelper.Dmsetup.create_snapshot_rw", include: [:name]) - def create_snapshot_rw(name, origin_dev, cow_dev, sectors) do - table = snapshot_table(origin_dev, cow_dev, sectors, Hyper.Cfg.Img.chunk_sectors()) - create(name, table, []) + def create_snapshot_rw(name, origin_dev, cow_file, sectors) do + table = snapshot_table(origin_dev, cow_file, sectors, Hyper.Cfg.Img.chunk_sectors()) + with {:ok, dev, _loops} <- create(name, table, []), do: {:ok, dev} end @doc "Send a thin-pool `message` to dm device `name`." @@ -167,17 +176,23 @@ defmodule Hyper.SuidHelper.Dmsetup do end end + @doc false + @spec linear_table(Path.t(), pos_integer()) :: String.t() + def linear_table(origin, sectors) do + "0 #{sectors} linear #{origin} 0" + end + @doc false @spec snapshot_table(Path.t(), Path.t(), pos_integer(), pos_integer()) :: String.t() - def snapshot_table(origin_dev, cow_dev, sectors, chunk_sectors) do - "0 #{sectors} snapshot #{origin_dev} #{cow_dev} P #{chunk_sectors}" + def snapshot_table(origin, cow_file, sectors, chunk_sectors) do + "0 #{sectors} snapshot #{origin} #{cow_file} P #{chunk_sectors}" end @doc false @spec thin_pool_table(Path.t(), Path.t(), pos_integer(), pos_integer(), non_neg_integer()) :: String.t() - def thin_pool_table(meta_dev, data_dev, sectors, block_sectors, low_water) do - "0 #{sectors} thin-pool #{meta_dev} #{data_dev} #{block_sectors} #{low_water}" + def thin_pool_table(meta_file, data_file, sectors, block_sectors, low_water) do + "0 #{sectors} thin-pool #{meta_file} #{data_file} #{block_sectors} #{low_water}" end @doc false @@ -188,12 +203,13 @@ defmodule Hyper.SuidHelper.Dmsetup do # Create a dm device named `name` from a reconstructed `table`, with any extra # create flags (e.g. `--readonly`). Returns the `/dev/mapper/` path. - @spec create(String.t(), String.t(), [String.t()]) :: {:ok, Path.t()} | {:error, err()} + @spec create(String.t(), String.t(), [String.t()]) :: + {:ok, Path.t(), %{String.t() => Path.t()}} | {:error, err()} defp create(name, table, flags) do argv = ["dmsetup", "create", name] ++ flags ++ ["--table", table] case SuidHelper.exec(argv) do - {:ok, %{"device" => dev}} -> {:ok, dev} + {:ok, %{"device" => dev, "loops" => loops}} -> {:ok, dev, loops} {:error, _} = err -> err end end diff --git a/lib/hyper/suid_helper/losetup.ex b/lib/hyper/suid_helper/losetup.ex deleted file mode 100644 index 3abf4fba..00000000 --- a/lib/hyper/suid_helper/losetup.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule Hyper.SuidHelper.Losetup do - @moduledoc "Loop-device operations, via the setuid helper's `losetup` tool." - - use OpenTelemetryDecorator - - alias Hyper.SuidHelper - - @type err :: SuidHelper.err() - - @doc "Attach `path` as a read-only loop-back block device." - @spec attach_ro(Path.t()) :: {:ok, Path.t()} | {:error, err()} - @decorate with_span("Hyper.SuidHelper.Losetup.attach_ro", include: [:path]) - def attach_ro(path) do - case SuidHelper.exec(["losetup", "attach", path]) do - {:ok, %{"device" => dev}} -> {:ok, dev} - {:error, _} = err -> err - end - end - - @doc "Attach `path` as a read-write loop-back block device." - @spec attach_rw(Path.t()) :: {:ok, Path.t()} | {:error, err()} - @decorate with_span("Hyper.SuidHelper.Losetup.attach_rw", include: [:path]) - def attach_rw(path) do - case SuidHelper.exec(["losetup", "attach", "--rw", path]) do - {:ok, %{"device" => dev}} -> {:ok, dev} - {:error, _} = err -> err - end - end - - @doc "Detach the loop block device at `dev`." - @spec detach(Path.t()) :: :ok | {:error, err()} - @decorate with_span("Hyper.SuidHelper.Losetup.detach", include: [:dev]) - def detach(dev) do - case SuidHelper.exec(["losetup", "detach", dev]) do - {:ok, _} -> :ok - {:error, _} = err -> err - end - end - - @doc "Currently-attached loop devices as `{device, backing_file}` pairs." - @spec list() :: {:ok, [{Path.t(), Path.t()}]} | {:error, err()} - @decorate with_span("Hyper.SuidHelper.Losetup.list") - def list do - case SuidHelper.exec(["losetup", "list"]) do - {:ok, %{"output" => out}} -> {:ok, parse_list(out)} - {:error, _} = err -> err - end - end - - @doc false - @spec parse_list(String.t()) :: [{Path.t(), Path.t()}] - def parse_list(out) do - out - |> String.split("\n", trim: true) - |> Enum.flat_map(fn line -> - # `NAME BACK-FILE` rows; a loop with no backing file has only one column - # (nothing for us to reclaim by file), so skip it. - case String.split(line, " ", parts: 2, trim: true) do - [dev, backing] -> [{dev, String.trim(backing)}] - _ -> [] - end - end) - end -end diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index cb96da28..91bd9511 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -112,10 +112,6 @@ path = "tests/e2e/chroot_jail.rs" name = "e2e_version" path = "tests/e2e/version.rs" -[[test]] -name = "e2e_losetup" -path = "tests/e2e/losetup.rs" - [[test]] name = "e2e_thin_dump" path = "tests/e2e/thin_dump.rs" @@ -129,8 +125,8 @@ name = "config_network" path = "tests/config_network.rs" [[test]] -name = "tools_losetup_backing" -path = "tests/tools/losetup_backing.rs" +name = "tools_dmsetup_backing" +path = "tests/tools/dmsetup_backing.rs" [dependencies] bon = "3.9.3" diff --git a/native/suidhelper/src/config.rs b/native/suidhelper/src/config.rs index f94f64ea..493280f6 100644 --- a/native/suidhelper/src/config.rs +++ b/native/suidhelper/src/config.rs @@ -135,8 +135,6 @@ pub struct Jails { pub struct Tools { #[default("/usr/sbin/dmsetup".into())] dmsetup: PathBuf, - #[default("/usr/sbin/losetup".into())] - losetup: PathBuf, // /usr/bin/ip, not /usr/sbin/ip: on merged-usr distros the latter // is a symlink to the former, and RootSafeBin rejects symlinked tool // paths. iproute2's real binary lives at /usr/bin/ip. @@ -220,11 +218,6 @@ impl Config { RootSafeBin::open(&self.tools.dmsetup) } - /// The validated `losetup` binary the helper will run. - pub fn losetup(&self) -> Result, binary::Error> { - RootSafeBin::open(&self.tools.losetup) - } - /// The validated `ip` (iproute2) binary the network tool runs. pub fn ip(&self) -> Result, binary::Error> { RootSafeBin::open(&self.tools.ip) diff --git a/native/suidhelper/src/fs/dev.rs b/native/suidhelper/src/fs/dev.rs new file mode 100644 index 00000000..eaa678e9 --- /dev/null +++ b/native/suidhelper/src/fs/dev.rs @@ -0,0 +1,41 @@ +use nix::libc::dev_t; +use nix::sys::stat::{major, makedev, minor}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DeviceNumber { + major: u64, + minor: u64, +} + +impl DeviceNumber { + pub const fn new(major: u64, minor: u64) -> Self { + Self { major, minor } + } + + pub fn major(self) -> u64 { + self.major + } + + pub fn minor(self) -> u64 { + self.minor + } +} + +impl From for DeviceNumber { + fn from(rdev: dev_t) -> Self { + Self::new(major(rdev), minor(rdev)) + } +} + +impl From for dev_t { + fn from(dev: DeviceNumber) -> Self { + makedev(dev.major, dev.minor) + } +} + +impl fmt::Display for DeviceNumber { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.major, self.minor) + } +} diff --git a/native/suidhelper/src/fs/dir.rs b/native/suidhelper/src/fs/dir.rs index 7edaace8..592b62d7 100644 --- a/native/suidhelper/src/fs/dir.rs +++ b/native/suidhelper/src/fs/dir.rs @@ -1,16 +1,18 @@ // SPDX-License-Identifier: MIT //! Safe directory operations. +use super::dev::DeviceNumber; use super::file::{ Any, FileType, IsDirectory, OpenFlags, Ownership, SafeFile, ValidationError, Writability, }; +use super::path::{StrictComponent, StrictComponentBuf}; use nix::dir::{Dir, Type}; use nix::errno::Errno; use nix::fcntl::{openat2, AtFlags, OFlag, OpenHow, ResolveFlag}; -use nix::libc::{self, dev_t}; +use nix::libc; use nix::sys::stat::{fstat, mkdirat, mknodat, FileStat, Mode, SFlag}; -use nix::unistd::{fchownat, linkat, unlinkat, Gid, Uid, UnlinkatFlags}; -use std::ffi::{OsStr, OsString}; +use nix::unistd::{linkat, unlinkat, UnlinkatFlags}; +use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; use std::path::Path; @@ -47,7 +49,7 @@ impl SafeFile { /// Open `name` relative to this directory and verify it as a [`SafeFile`]. pub fn openat( &self, - name: &Path, + name: impl AsRef, flags: OpenFlags, mode: Mode, ) -> Result, ValidationError> { @@ -55,7 +57,7 @@ impl SafeFile { .flags(OFlag::from(flags) | T::OPEN_FLAGS | OFlag::O_CLOEXEC) .mode(mode) .resolve(ResolveFlag::RESOLVE_NO_SYMLINKS | ResolveFlag::RESOLVE_BENEATH); - let raw = openat2(self.as_raw_fd(), name, how).map_err(ValidationError::Open)?; + let raw = openat2(self.as_raw_fd(), name.as_ref(), how).map_err(ValidationError::Open)?; // SAFETY: openat2 just handed us this fd; nobody else owns it. SafeFile::try_from(unsafe { OwnedFd::from_raw_fd(raw) }) } @@ -63,79 +65,92 @@ impl SafeFile { /// Create file `name` in this directory, failing if it exists. pub fn mkfileat( &self, - name: &Path, + name: &StrictComponent, mode: Mode, ) -> Result, ValidationError> { self.openat(name, OpenFlags::WRONLY | OpenFlags::CREATE_NEW, mode) } /// Create a device file. - pub fn mknodat(&self, name: &Path, kind: SFlag, mode: Mode, rdev: dev_t) -> nix::Result<()> { - mknodat(Some(self.as_raw_fd()), name, kind, mode, rdev) + pub fn mknodat( + &self, + name: &StrictComponent, + kind: SFlag, + mode: Mode, + rdev: DeviceNumber, + ) -> nix::Result<()> { + mknodat( + Some(self.as_raw_fd()), + name.as_path(), + kind, + mode, + rdev.into(), + ) } /// Create directory `name` in this directory with `mode`, tolerating `EEXIST`. - pub fn mkdirat(&self, name: &Path, mode: Mode) -> nix::Result<()> { - match mkdirat(Some(self.as_raw_fd()), name, mode) { + pub fn mkdirat(&self, name: &StrictComponent, mode: Mode) -> nix::Result<()> { + match mkdirat(Some(self.as_raw_fd()), name.as_path(), mode) { Ok(()) | Err(Errno::EEXIST) => Ok(()), Err(errno) => Err(errno), } } /// Hard link the `src` file into `name`. - pub fn linkat(&self, src: &SafeFile, name: &Path) -> nix::Result<()> { + pub fn linkat( + &self, + src: &SafeFile, + name: &StrictComponent, + ) -> nix::Result<()> { linkat( Some(src.as_raw_fd()), Path::new(""), Some(self.as_raw_fd()), - name, + name.as_path(), AtFlags::AT_EMPTY_PATH, ) } - /// `chown` entry `name` to `uid:gid` without following a final symlink. - pub fn chownat(&self, name: &Path, uid: u32, gid: u32) -> nix::Result<()> { - fchownat( - Some(self.as_raw_fd()), - name, - Some(Uid::from_raw(uid)), - Some(Gid::from_raw(gid)), - AtFlags::AT_SYMLINK_NOFOLLOW, - ) - } - /// Stat a file at the current directory. /// /// Note that this must remain private as this function is a footgun. It's very dangerous and /// risky to use this function as it causes an array of TOCTOU bugs. - fn statat(&self, name: &Path) -> nix::Result { + fn statat(&self, name: &StrictComponent) -> nix::Result { let how = OpenHow::new() .flags(OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC) .resolve(ResolveFlag::RESOLVE_NO_SYMLINKS | ResolveFlag::RESOLVE_BENEATH); - let raw = openat2(self.as_raw_fd(), name, how)?; + let raw = openat2(self.as_raw_fd(), name.as_path(), how)?; // SAFETY: openat2 just handed us this fd; nobody else owns it. let fd = unsafe { OwnedFd::from_raw_fd(raw) }; fstat(fd.as_raw_fd()) } /// Remove the non-directory entry `name` from this directory. - pub fn unlinkat(&self, name: &Path) -> nix::Result<()> { - unlinkat(Some(self.as_raw_fd()), name, UnlinkatFlags::NoRemoveDir) + pub fn unlinkat(&self, name: &StrictComponent) -> nix::Result<()> { + unlinkat( + Some(self.as_raw_fd()), + name.as_path(), + UnlinkatFlags::NoRemoveDir, + ) } /// Remove the empty child directory `name` from this directory. - pub fn rmdirat(&self, name: &Path) -> nix::Result<()> { - unlinkat(Some(self.as_raw_fd()), name, UnlinkatFlags::RemoveDir) + pub fn rmdirat(&self, name: &StrictComponent) -> nix::Result<()> { + unlinkat( + Some(self.as_raw_fd()), + name.as_path(), + UnlinkatFlags::RemoveDir, + ) } /// Recursively, without following symlinks, remove child `name` and everything under it. - pub fn rmdirat_recursive(&self, name: &Path) -> Result<(), Error> { + pub fn rmdirat_recursive(&self, name: &StrictComponent) -> Result<(), Error> { let child: SafeDir = self.openat(name, OpenFlags::RDONLY, Mode::empty())?; let entries = child.entries()?.collect::>>()?; for (entry, ty) in entries { match ty { - Type::Directory => child.rmdirat_recursive(Path::new(&entry))?, - _ => child.unlinkat(Path::new(&entry))?, + Type::Directory => child.rmdirat_recursive(&entry)?, + _ => child.unlinkat(&entry)?, } } Ok(self.rmdirat(name)?) @@ -144,7 +159,7 @@ impl SafeFile { /// Get an iterator through all the entries in this directory. pub fn entries( &self, - ) -> Result> + '_, Error> { + ) -> Result> + '_, Error> { let fresh: SafeDir = self.openat(Path::new("."), OpenFlags::RDONLY, Mode::empty())?; Ok(Dir::from(OwnedFd::from(fresh))? @@ -160,11 +175,15 @@ impl SafeFile { return None; } - let name = OsStr::from_bytes(bytes).to_os_string(); + let name: &StrictComponent = match OsStr::from_bytes(bytes).try_into() { + Ok(name) => name, + Err(_) => return Some(Err(Errno::EINVAL)), + }; let ty = match entry.file_type() { Some(ty) => Ok(ty), - None => self.statat(Path::new(&name)).and_then(|stat| { - match stat.st_mode & libc::S_IFMT { + None => self + .statat(name) + .and_then(|stat| match stat.st_mode & libc::S_IFMT { libc::S_IFDIR => Ok(Type::Directory), libc::S_IFREG => Ok(Type::File), libc::S_IFLNK => Ok(Type::Symlink), @@ -173,11 +192,10 @@ impl SafeFile { libc::S_IFIFO => Ok(Type::Fifo), libc::S_IFSOCK => Ok(Type::Socket), _ => Err(Errno::EINVAL), - } - }), + }), }; - Some(ty.map(|ty| (name, ty))) + Some(ty.map(|ty| (name.to_owned(), ty))) })) } } diff --git a/native/suidhelper/src/fs/file.rs b/native/suidhelper/src/fs/file.rs index b6ca07ac..b9178c35 100644 --- a/native/suidhelper/src/fs/file.rs +++ b/native/suidhelper/src/fs/file.rs @@ -1,13 +1,12 @@ // SPDX-License-Identifier: MIT //! Typesafer file descriptors. +use super::dev::DeviceNumber; use super::path::{IsAbsolute, SafePath, StrictComponents}; use derive_more::{BitOr, Into}; -use nix::fcntl::{openat2, AtFlags, OFlag, OpenHow, ResolveFlag}; -use nix::libc::{dev_t, AT_FDCWD}; -use nix::sys::stat::{ - fchmodat, fstat, major, makedev, minor, FchmodatFlags, FileStat, Mode, SFlag, -}; +use nix::fcntl::{open, openat2, AtFlags, OFlag, OpenHow, ResolveFlag}; +use nix::libc::AT_FDCWD; +use nix::sys::stat::{fchmodat, fstat, FchmodatFlags, FileStat, Mode, SFlag}; use nix::unistd::{dup, fchownat, Gid, Uid}; use std::io; use std::marker::PhantomData; @@ -215,13 +214,20 @@ impl SafeFile { } } -impl SafeFile { +pub trait DeviceType: FileType {} + +impl DeviceType for IsBlockDevice {} +impl DeviceType for IsCharDevice {} + +impl SafeFile { /// The device's `rdev` (major:minor), ready for `mknod`. - pub fn rdev(&self) -> Result { + pub fn rdev(&self) -> Result { let stat = fstat(self.0.as_raw_fd()).map_err(ValidationError::Fstat)?; - Ok(makedev(major(stat.st_rdev), minor(stat.st_rdev))) + Ok(DeviceNumber::from(stat.st_rdev)) } +} +impl SafeFile { pub fn size(&self) -> nix::Result { let mut bytes = 0u64; // SAFETY: BLKGETSIZE64 writes exactly one u64 through the pointer. @@ -254,6 +260,20 @@ where Self::try_from(owned) } + + pub fn reopen(&self, flags: OpenFlags) -> Result { + let path = PathBuf::from(format!("/proc/self/fd/{}", self.0.as_raw_fd())); + let raw = open( + &path, + flags.0 | T::OPEN_FLAGS | OFlag::O_CLOEXEC, + Mode::empty(), + ) + .map_err(ValidationError::Open)?; + // SAFETY: `open` just handed us this fd; nobody else owns it. + let owned = unsafe { OwnedFd::from_raw_fd(raw) }; + + Self::try_from(owned) + } } impl io::Write for SafeFile { diff --git a/native/suidhelper/src/fs/mod.rs b/native/suidhelper/src/fs/mod.rs index 7abc49a8..fb36929c 100644 --- a/native/suidhelper/src/fs/mod.rs +++ b/native/suidhelper/src/fs/mod.rs @@ -1,5 +1,6 @@ //! Filesystem utilities. +pub mod dev; pub mod dir; pub mod file; pub mod path; diff --git a/native/suidhelper/src/fs/path.rs b/native/suidhelper/src/fs/path.rs index 15ab5617..1f2ba930 100644 --- a/native/suidhelper/src/fs/path.rs +++ b/native/suidhelper/src/fs/path.rs @@ -3,19 +3,153 @@ use derive_more::AsRef; use ref_cast::{ref_cast_custom, RefCastCustom}; +use std::borrow::Borrow; use std::ffi::OsStr; +use std::fmt; use std::marker::PhantomData; use std::ops::Deref; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use thiserror::Error as ThisError; +pub const NAME_MAX: usize = 255; + #[derive(Debug, ThisError)] pub enum ValidationError { #[error("path is not absolute")] NotAbsolute, - #[error("path contains `.`, `..`, or empty components")] - LooseComponents, + #[error("path component: {0}")] + Component(#[from] ComponentError), +} + +#[derive(Debug, ThisError)] +pub enum ComponentError { + #[error("empty")] + Empty, + #[error("`.`")] + CurrentDir, + #[error("`..`")] + ParentDir, + #[error("contains `/`")] + Separator, + #[error("contains a NUL byte")] + Nul, + #[error("{0} bytes, longer than NAME_MAX ({NAME_MAX})")] + TooLong(usize), +} + +/// A path component that isn't `.`, nor `..`, nor `/`. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, RefCastCustom)] +#[repr(transparent)] +pub struct StrictComponent([u8]); + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct StrictComponentBuf(Box<[u8]>); + +impl StrictComponent { + #[ref_cast_custom] + const fn new_unchecked(bytes: &[u8]) -> &Self; + + const fn check(bytes: &[u8]) -> Result<(), ComponentError> { + match bytes { + [] => return Err(ComponentError::Empty), + b"." => return Err(ComponentError::CurrentDir), + b".." => return Err(ComponentError::ParentDir), + _ => {} + } + if bytes.len() > NAME_MAX { + return Err(ComponentError::TooLong(bytes.len())); + } + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'/' => return Err(ComponentError::Separator), + 0 => return Err(ComponentError::Nul), + _ => i += 1, + } + } + Ok(()) + } + + pub const fn from_static(name: &'static str) -> &'static Self { + match Self::check(name.as_bytes()) { + Ok(()) => Self::new_unchecked(name.as_bytes()), + Err(_) => panic!("not a strict path component"), + } + } + + pub fn as_os_str(&self) -> &OsStr { + OsStr::from_bytes(&self.0) + } + + pub fn as_path(&self) -> &Path { + Path::new(self.as_os_str()) + } +} + +impl<'a> TryFrom<&'a OsStr> for &'a StrictComponent { + type Error = ComponentError; + fn try_from(name: &'a OsStr) -> Result { + StrictComponent::check(name.as_bytes())?; + Ok(StrictComponent::new_unchecked(name.as_bytes())) + } +} + +impl<'a> TryFrom<&'a str> for &'a StrictComponent { + type Error = ComponentError; + fn try_from(name: &'a str) -> Result { + OsStr::new(name).try_into() + } +} + +impl AsRef for StrictComponent { + fn as_ref(&self) -> &OsStr { + self.as_os_str() + } +} + +impl AsRef for StrictComponent { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl fmt::Debug for StrictComponent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_os_str().fmt(f) + } +} + +impl ToOwned for StrictComponent { + type Owned = StrictComponentBuf; + fn to_owned(&self) -> StrictComponentBuf { + StrictComponentBuf(self.0.into()) + } +} + +impl Deref for StrictComponentBuf { + type Target = StrictComponent; + fn deref(&self) -> &StrictComponent { + StrictComponent::new_unchecked(&self.0) + } +} + +impl Borrow for StrictComponentBuf { + fn borrow(&self) -> &StrictComponent { + self + } +} + +impl AsRef for StrictComponentBuf { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl fmt::Debug for StrictComponentBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + (**self).fmt(f) + } } #[derive(Debug, ThisError)] @@ -61,14 +195,14 @@ impl Components for StrictComponents { // This implementation cannot use Path::components because it // normalizes path elements. let raw = path.as_os_str().as_bytes(); + if raw == b"/" { + return Ok(()); + } let body = raw.strip_prefix(b"/").unwrap_or(raw); - - let is_plain_name = |seg: &[u8]| !seg.is_empty() && seg != b"." && seg != b".."; - if body.split(|&b| b == b'/').all(is_plain_name) { - Ok(()) - } else { - Err(ValidationError::LooseComponents) + for segment in body.split(|&b| b == b'/') { + StrictComponent::check(segment)?; } + Ok(()) } } @@ -109,16 +243,30 @@ impl SafePath { StrictComponents::check(rest).map_err(|_| RelativeError::NotUnderBase)?; Ok(SafePath::new_unchecked(rest)) } + + pub fn split_last(&self) -> Option<(&SafePath, &StrictComponent)> { + let raw = self.path.as_os_str().as_bytes(); + let slash = raw.iter().rposition(|&b| b == b'/')?; + let name = &raw[slash + 1..]; + if name.is_empty() { + return None; + } + let parent = &raw[..slash.max(1)]; + Some(( + SafePath::new_unchecked(Path::new(OsStr::from_bytes(parent))), + StrictComponent::new_unchecked(name), + )) + } } impl SafePath { /// Given `foo/bar/baz`, returns `[foo, bar, baz]`. - pub fn segments(&self) -> impl Iterator { + pub fn segments(&self) -> impl Iterator { self.path .as_os_str() .as_bytes() .split(|&b| b == b'/') - .map(OsStr::from_bytes) + .map(StrictComponent::new_unchecked) } } diff --git a/native/suidhelper/src/linux/cgroup/mod.rs b/native/suidhelper/src/linux/cgroup/mod.rs index 7097dc6d..7a57099f 100644 --- a/native/suidhelper/src/linux/cgroup/mod.rs +++ b/native/suidhelper/src/linux/cgroup/mod.rs @@ -4,22 +4,26 @@ pub use events::{Events, EventsState, ReadError}; use crate::fs::dir::SafeDir; use crate::fs::file::{self, Any, IsDirectory, OpenFlags, SafeFile}; -use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::fs::path::{ + IsAbsolute, SafePath, StrictComponent, StrictComponentBuf, StrictComponents, +}; use nix::errno::Errno; use nix::sys::stat::Mode; use nix::sys::statfs::{fstatfs, CGROUP2_SUPER_MAGIC}; -use std::ffi::OsString; use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::{Duration, Instant}; use thiserror::Error as ThisError; +pub const ROOT: &str = "/sys/fs/cgroup"; + +const KILL: &StrictComponent = StrictComponent::from_static("cgroup.kill"); +const EVENTS: &StrictComponent = StrictComponent::from_static("cgroup.events"); + #[derive(Debug, ThisError)] pub enum OpenError { #[error("cgroup path has no parent: {0:?}")] NoParent(PathBuf), - #[error("cgroup parent path: {0}")] - Path(#[from] path::ValidationError), #[error(transparent)] Open(#[from] file::ValidationError), #[error("statfs on cgroup: {0}")] @@ -48,28 +52,25 @@ pub enum KillError { pub struct Cgroup { parent: SafeDir, - name: OsString, + name: StrictComponentBuf, } impl Cgroup { /// Open a handle to this cgroup. pub fn open(path: &SafePath) -> Result { - let full: &Path = path.as_ref(); - let (Some(parent), Some(name)) = (full.parent(), full.file_name()) else { - return Err(OpenError::NoParent(full.to_path_buf())); + let Some((parent, name)) = path.split_last() else { + return Err(OpenError::NoParent(path.as_ref().to_path_buf())); }; - - let parent: &SafePath = parent.try_into()?; let parent = SafeDir::open(parent, OpenFlags::RDONLY)?; - let leaf: SafeDir = parent.openat(Path::new(name), OpenFlags::RDONLY, Mode::empty())?; + let leaf: SafeDir = parent.openat(name, OpenFlags::RDONLY, Mode::empty())?; if fstatfs(&leaf).map_err(OpenError::Statfs)?.filesystem_type() != CGROUP2_SUPER_MAGIC { - return Err(OpenError::NotCgroup(full.to_path_buf())); + return Err(OpenError::NotCgroup(path.as_ref().to_path_buf())); } Ok(Self { parent, - name: name.to_os_string(), + name: name.to_owned(), }) } @@ -83,7 +84,7 @@ impl Cgroup { Err(e) => return Err(KillError::Events(e)), }; - let kill_path = Path::new(&self.name).join("cgroup.kill"); + let kill_path = self.name.as_path().join(KILL); let mut kill: SafeFile = match self .parent @@ -93,7 +94,7 @@ impl Cgroup { Err(file::ValidationError::Open(Errno::ENODEV)) => return Ok(()), Err(file::ValidationError::Open(Errno::ENOENT)) => { return match self.parent.openat::( - Path::new(&self.name), + &*self.name, OpenFlags::PATH, Mode::empty(), ) { @@ -128,7 +129,7 @@ impl Cgroup { /// Fetch a handle to the events of this cgroup. pub fn events(&self) -> Result, file::ValidationError> { let file = self.parent.openat( - &Path::new(&self.name).join("cgroup.events"), + self.name.as_path().join(EVENTS), OpenFlags::RDONLY, Mode::empty(), )?; @@ -137,7 +138,7 @@ impl Cgroup { /// Remove the cgroup. pub fn remove(self) -> Result<(), Errno> { - match self.parent.rmdirat(Path::new(&self.name)) { + match self.parent.rmdirat(&self.name) { Ok(()) | Err(Errno::ENOENT) => Ok(()), Err(e) => Err(e), } diff --git a/native/suidhelper/src/linux/dm/mod.rs b/native/suidhelper/src/linux/dm/mod.rs index 078e292a..69502a5d 100644 --- a/native/suidhelper/src/linux/dm/mod.rs +++ b/native/suidhelper/src/linux/dm/mod.rs @@ -5,11 +5,12 @@ mod table; mod thin; pub mod thin_dump; +use super::Access; use crate::process::binary::RootSafeBin; pub use device::Device; use std::ffi::OsStr; use std::io; -pub use table::{DmTable, Persistence, SnapshotTable, ThinPoolTable, ThinTable}; +pub use table::{DmTable, LinearTable, Persistence, SnapshotTable, ThinPoolTable, ThinTable}; pub use thin::{ Active, Inactive, SnapshotError, Thin, ThinId, ThinIdOutOfRange, ThinPool, ThinPoolError, }; @@ -37,12 +38,6 @@ pub struct Target { pub version: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Access { - ReadOnly, - ReadWrite, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoveMode { Once, diff --git a/native/suidhelper/src/linux/dm/table.rs b/native/suidhelper/src/linux/dm/table.rs index 40d19887..4169ec87 100644 --- a/native/suidhelper/src/linux/dm/table.rs +++ b/native/suidhelper/src/linux/dm/table.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; /// Dmsetup tables. See `man dmsetup` for more info, under the `--table` docs. #[derive(Debug, Clone, Display)] pub enum DmTable { + Linear(LinearTable), Snapshot(SnapshotTable), ThinPool(ThinPoolTable), Thin(ThinTable), @@ -18,6 +19,13 @@ pub enum Persistence { Transient, } +#[derive(Debug, Clone, Display)] +#[display("0 {sectors} linear {} 0", origin.display())] +pub struct LinearTable { + pub sectors: u64, + pub origin: PathBuf, +} + /// `dm-snapshot` table. #[derive(Debug, Clone, Display)] #[display("0 {sectors} snapshot {} {} {persistence} {chunk}", origin.display(), cow.display())] diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs index 232d1ad5..81682a1b 100644 --- a/native/suidhelper/src/linux/dm/thin.rs +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -1,4 +1,5 @@ -use super::{Access, Device, DmTable, Dmsetup, RemoveMode, RunError, ThinTable}; +use super::{Device, DmTable, Dmsetup, RemoveMode, RunError, ThinTable}; +use crate::linux::Access; use std::fmt; use std::path::PathBuf; use thiserror::Error as ThisError; diff --git a/native/suidhelper/src/linux/loopdev.rs b/native/suidhelper/src/linux/loopdev.rs new file mode 100644 index 00000000..63937ff1 --- /dev/null +++ b/native/suidhelper/src/linux/loopdev.rs @@ -0,0 +1,196 @@ +//! Utilities for interacting with `/dev/loop-control`. +use super::Access; +use crate::fs::dev::DeviceNumber; +use crate::fs::file::{self, Any, IsBlockDevice, IsCharDevice, IsRegularFile, OpenFlags, SafeFile}; +use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; +use nix::errno::Errno; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use thiserror::Error as ThisError; + +const CONTROL_PATH: &str = "/dev/loop-control"; +const LOOP_CONTROL: DeviceNumber = DeviceNumber::new(10, 237); +const LOOP_MAJOR: u64 = 7; +const ATTACH_BACKOFF: Duration = Duration::from_millis(5); + +const LO_FLAGS_READ_ONLY: u32 = 1; +const LO_FLAGS_AUTOCLEAR: u32 = 4; + +mod ioctl { + use super::LoopConfig; + + nix::ioctl_none_bad!(ctl_get_free, 0x4C82); + nix::ioctl_write_ptr_bad!(configure, 0x4C0A, LoopConfig); +} + +#[repr(C)] +struct LoopInfo64 { + lo_device: u64, + lo_inode: u64, + lo_rdevice: u64, + lo_offset: u64, + lo_sizelimit: u64, + lo_number: u32, + lo_encrypt_type: u32, + lo_encrypt_key_size: u32, + lo_flags: u32, + lo_file_name: [u8; 64], + lo_crypt_name: [u8; 64], + lo_encrypt_key: [u8; 32], + lo_init: [u64; 2], +} + +#[derive(Default)] +#[repr(C)] +struct LoopConfig { + fd: u32, + block_size: u32, + info: LoopInfo64, + reserved: [u64; 8], +} + +const _: () = assert!(std::mem::size_of::() == 232); +const _: () = assert!(std::mem::size_of::() == 304); + +impl Default for LoopInfo64 { + fn default() -> Self { + Self { + lo_device: 0, + lo_inode: 0, + lo_rdevice: 0, + lo_offset: 0, + lo_sizelimit: 0, + lo_number: 0, + lo_encrypt_type: 0, + lo_encrypt_key_size: 0, + lo_flags: 0, + lo_file_name: [0; 64], + lo_crypt_name: [0; 64], + lo_encrypt_key: [0; 32], + lo_init: [0; 2], + } + } +} + +#[derive(Debug, ThisError)] +pub enum OpenError { + #[error("loop device path: {0}")] + Path(#[from] path::ValidationError), + #[error(transparent)] + Open(#[from] file::ValidationError), + #[error("{path:?} is device {device}, not the expected device")] + WrongDevice { path: PathBuf, device: DeviceNumber }, +} + +#[derive(Debug, ThisError)] +pub enum AttachError { + #[error("reopening backing file: {0}")] + Backing(#[source] file::ValidationError), + #[error("LOOP_CTL_GET_FREE: {0}")] + GetFree(#[source] Errno), + #[error("kernel returned invalid loop number {0}")] + Number(i32), + #[error("opening loop device: {0}")] + Open(#[source] OpenError), + #[error("LOOP_CONFIGURE: {0}")] + Configure(#[source] Errno), + #[error("no free loop device after {0:?}")] + TimedOut(Duration), +} + +pub struct LoopControl(SafeFile); + +impl LoopControl { + pub fn open() -> Result { + let path: &SafePath = Path::new(CONTROL_PATH).try_into()?; + let file: SafeFile = SafeFile::open(path, OpenFlags::RDWR)?; + let device = file.rdev()?; + if device != LOOP_CONTROL { + return Err(OpenError::WrongDevice { + path: path.as_ref().to_path_buf(), + device, + }); + } + Ok(Self(file)) + } + + pub fn attach( + &self, + backing: &SafeFile, + access: Access, + timeout: Duration, + ) -> Result + where + R: file::Ownership, + O: file::Writability, + { + let deadline = Instant::now() + timeout; + + let (open_flags, lo_flags) = match access { + Access::ReadOnly => (OpenFlags::RDONLY, LO_FLAGS_AUTOCLEAR | LO_FLAGS_READ_ONLY), + Access::ReadWrite => (OpenFlags::RDWR, LO_FLAGS_AUTOCLEAR), + }; + + let backing = backing.reopen(open_flags).map_err(AttachError::Backing)?; + let config = LoopConfig { + fd: backing.as_raw_fd() as u32, + info: LoopInfo64 { + lo_flags, + ..LoopInfo64::default() + }, + ..LoopConfig::default() + }; + + loop { + // SAFETY: LOOP_CTL_GET_FREE takes no argument. + let raw = + unsafe { ioctl::ctl_get_free(self.0.as_raw_fd()) }.map_err(AttachError::GetFree)?; + let number = u32::try_from(raw).map_err(|_| AttachError::Number(raw))?; + let device = LoopDevice::open(number).map_err(AttachError::Open)?; + // SAFETY: `config` is a fully initialised `struct loop_config`. + match unsafe { ioctl::configure(device.file.as_raw_fd(), &config) } { + Ok(_) => return Ok(device), + Err(Errno::EBUSY) => {} + Err(e) => return Err(AttachError::Configure(e)), + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(AttachError::TimedOut(timeout)); + } + std::thread::sleep(ATTACH_BACKOFF.min(remaining)); + } + } +} + +pub struct LoopDevice { + number: u32, + path: SafePathBuf, + file: SafeFile, +} + +impl LoopDevice { + pub fn open(number: u32) -> Result { + let path: SafePathBuf = + PathBuf::from(format!("/dev/loop{number}")).try_into()?; + let file: SafeFile = SafeFile::open(&path, OpenFlags::RDWR)?; + let device = file.rdev()?; + + if device.major() != LOOP_MAJOR { + return Err(OpenError::WrongDevice { + path: path.as_ref().to_path_buf(), + device, + }); + } + + Ok(Self { number, path, file }) + } + + pub fn number(&self) -> u32 { + self.number + } + + pub fn path(&self) -> &SafePath { + &self.path + } +} diff --git a/native/suidhelper/src/linux/mod.rs b/native/suidhelper/src/linux/mod.rs index 908f5eec..793de6c5 100644 --- a/native/suidhelper/src/linux/mod.rs +++ b/native/suidhelper/src/linux/mod.rs @@ -2,3 +2,10 @@ pub mod cgroup; pub mod dm; +pub mod loopdev; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + ReadOnly, + ReadWrite, +} diff --git a/native/suidhelper/src/tools/chroot_jail/grant.rs b/native/suidhelper/src/tools/chroot_jail/grant.rs index 5abacfc4..1d44ce61 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant.rs @@ -9,12 +9,11 @@ use crate::fs::dir::SafeDir; use crate::fs::file::{self, Any, IsSocket, OpenFlags, SafeFile}; -use crate::fs::path::{IsAbsolute, SafePath, StrictComponents}; +use crate::fs::path::StrictComponent; use nix::errno::Errno; use nix::sys::stat::Mode; use nix::unistd::{getgid, getuid}; use serde::Serialize; -use std::path::Path; use thiserror::Error as ThisError; /// Mode handed to the node: owner+group read/write, no world access. @@ -53,34 +52,13 @@ pub enum GrantError { ChmodRoot(#[source] Errno), } -/// Open `base` and walk `parents` from it (`O_NOFOLLOW` each step). Returns -/// `Ok(None)` if `base` or any parent is not yet present (`ENOENT`), so the -/// caller can treat a half-built jail as `Pending` rather than an error. On any -/// other failure the raw `file::ValidationError` is returned for the caller to wrap in -/// its own error type. -pub fn walk_to( - base: &SafePath, - rel: &Path, -) -> Result, file::ValidationError> { - let anchor = match SafeDir::open(base, OpenFlags::RDONLY) { - Ok(dir) => dir, - Err(file::ValidationError::Open(Errno::ENOENT)) => return Ok(None), - Err(e) => return Err(e), - }; - match anchor.openat(rel, OpenFlags::RDONLY, Mode::empty()) { - Ok(dir) => Ok(Some(dir)), - Err(file::ValidationError::Open(Errno::ENOENT)) => Ok(None), - Err(e) => Err(e), - } -} - /// Stat `leaf` under `root` (`AT_SYMLINK_NOFOLLOW`), verify it is a real socket /// (`S_IFSOCK`; a symlink reports `S_IFLNK` and is refused), then chown it to /// the caller and chmod `0660`. Also open the parent `root` dir for the caller's /// group to traverse (chgrp'd to the caller, chmod'd `0710`) so the node can /// reach the socket even though the jailer left `root` at `0700`. Returns /// `Pending` if `leaf` is not yet present (`ENOENT`). -pub fn grant_to_caller(root: SafeDir, leaf: &Path) -> Result { +pub fn grant_to_caller(root: SafeDir, leaf: &StrictComponent) -> Result { let sock: SafeFile = match root.openat(leaf, OpenFlags::PATH, Mode::empty()) { Ok(sock) => sock, diff --git a/native/suidhelper/src/tools/chroot_jail/grant_api.rs b/native/suidhelper/src/tools/chroot_jail/grant_api.rs index 446c526e..39e0c065 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant_api.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant_api.rs @@ -33,10 +33,12 @@ use super::grant; use crate::config::Config; -use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; +use crate::fs::dir::SafeDir; +use crate::fs::file::{self, OpenFlags}; +use crate::fs::path::{self, IsAbsolute, SafePathBuf, StrictComponent, StrictComponents}; use crate::tools::IsTool; use clap::Args; -use std::ffi::OsStr; +use nix::errno::Errno; use std::path::{Path, PathBuf}; use std::str::FromStr; use thiserror::Error as ThisError; @@ -45,9 +47,7 @@ pub use super::grant::{GrantError, GrantOut}; /// The fixed in-jail socket name firecracker opens (mirrors the Elixir /// `Hyper.Node.FireVMM.Jailer` `@jail_socket`). -const SOCKET_NAME: &str = "api.socket"; - -type LexicalPath = SafePath; +const SOCKET_NAME: &StrictComponent = StrictComponent::from_static("api.socket"); #[derive(Debug, ThisError)] pub enum Error { @@ -79,7 +79,7 @@ impl FromStr for ApiSocket { fn from_str(s: &str) -> Result { let path: SafePathBuf = PathBuf::from(s).try_into().map_err(Error::SocketPath)?; - if path.as_ref().file_name() != Some(OsStr::new(SOCKET_NAME)) { + if path.split_last().map(|(_, leaf)| leaf) != Some(SOCKET_NAME) { return Err(Error::SocketName(PathBuf::from(s))); } Ok(Self(path)) @@ -122,16 +122,15 @@ impl IsTool for GrantApi { /// `Pending` if any path component or the socket itself is not yet present. pub fn grant_api_under(jail_base: &Path, socket: &ApiSocket) -> Result { let rel = socket.0.strip_base(jail_base).map_err(Error::SocketBase)?; - let [exec, id, root, _leaf] = rel.segments().collect::>()[..] else { + let (4, Some((root, leaf))) = (rel.segments().count(), socket.0.split_last()) else { return Err(Error::SocketShape(socket.0.as_ref().to_path_buf())); }; - let base_path: &LexicalPath = jail_base.try_into().map_err(Error::SocketPath)?; - let Some(root) = grant::walk_to(base_path, &[exec, id, root].iter().collect::()) - .map_err(Error::Walk)? - else { - return Ok(GrantOut::Pending); + let root = match SafeDir::open(root, OpenFlags::RDONLY) { + Ok(root) => root, + Err(file::ValidationError::Open(Errno::ENOENT)) => return Ok(GrantOut::Pending), + Err(e) => return Err(Error::Walk(e)), }; - grant::grant_to_caller(root, Path::new(SOCKET_NAME)).map_err(Error::from) + grant::grant_to_caller(root, leaf).map_err(Error::from) } diff --git a/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs b/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs index 41328e9b..2ffd04fc 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs @@ -17,10 +17,12 @@ use super::grant; use crate::config::Config; -use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; +use crate::fs::dir::SafeDir; +use crate::fs::file::{self, OpenFlags}; +use crate::fs::path::{self, IsAbsolute, SafePathBuf, StrictComponent, StrictComponents}; use crate::tools::IsTool; use clap::Args; -use std::ffi::OsStr; +use nix::errno::Errno; use std::path::{Path, PathBuf}; use std::str::FromStr; use thiserror::Error as ThisError; @@ -29,9 +31,7 @@ pub use super::grant::{GrantError, GrantOut}; /// The fixed in-jail socket name Firecracker opens for vsock (mirrors the Elixir /// `Hyper.Node.FireVMM.Jailer` `@jail_vsock`). -const VSOCK_NAME: &str = "vsock.sock"; - -type LexicalPath = SafePath; +const VSOCK_NAME: &StrictComponent = StrictComponent::from_static("vsock.sock"); #[derive(Debug, ThisError)] pub enum Error { @@ -64,7 +64,7 @@ impl FromStr for VsockSocket { fn from_str(s: &str) -> Result { let path: SafePathBuf = PathBuf::from(s).try_into().map_err(Error::SocketPath)?; - if path.as_ref().file_name() != Some(OsStr::new(VSOCK_NAME)) { + if path.split_last().map(|(_, leaf)| leaf) != Some(VSOCK_NAME) { return Err(Error::SocketName(PathBuf::from(s))); } Ok(Self(path)) @@ -107,16 +107,15 @@ impl IsTool for GrantVsock { /// `Pending` if any path component or the socket itself is not yet present. pub fn grant_vsock_under(jail_base: &Path, socket: &VsockSocket) -> Result { let rel = socket.0.strip_base(jail_base).map_err(Error::SocketBase)?; - let [exec, id, root, _leaf] = rel.segments().collect::>()[..] else { + let (4, Some((root, leaf))) = (rel.segments().count(), socket.0.split_last()) else { return Err(Error::SocketShape(socket.0.as_ref().to_path_buf())); }; - let base_path: &LexicalPath = jail_base.try_into().map_err(Error::SocketPath)?; - let Some(root) = grant::walk_to(base_path, &[exec, id, root].iter().collect::()) - .map_err(Error::Walk)? - else { - return Ok(GrantOut::Pending); + let root = match SafeDir::open(root, OpenFlags::RDONLY) { + Ok(root) => root, + Err(file::ValidationError::Open(Errno::ENOENT)) => return Ok(GrantOut::Pending), + Err(e) => return Err(Error::Walk(e)), }; - grant::grant_to_caller(root, Path::new(VSOCK_NAME)).map_err(Error::from) + grant::grant_to_caller(root, leaf).map_err(Error::from) } diff --git a/native/suidhelper/src/tools/chroot_jail/remove.rs b/native/suidhelper/src/tools/chroot_jail/remove.rs index d85b3296..c47016ad 100644 --- a/native/suidhelper/src/tools/chroot_jail/remove.rs +++ b/native/suidhelper/src/tools/chroot_jail/remove.rs @@ -21,43 +21,51 @@ use crate::linux::cgroup::{self, Cgroup}; use crate::tools::IsTool; use clap::Args; use nix::errno::Errno; -use nix::sys::stat::Mode; use serde::Serialize; use std::path::{Path, PathBuf}; use std::time::Duration; use thiserror::Error as ThisError; -/// The cgroup virtual filesystem root. -const CGROUP_BASE: &str = "/sys/fs/cgroup"; - const KILL_TIMEOUT: Duration = Duration::from_secs(5); type LexicalPath = SafePath; #[derive(Debug, ThisError)] pub enum Error { + #[error(transparent)] + Chroot(#[from] ChrootError), + #[error(transparent)] + Cgroup(#[from] CgroupError), +} + +#[derive(Debug, ThisError)] +pub enum ChrootError { #[error("--chroot path: {0}")] - ChrootPath(#[source] path::ValidationError), + Path(#[source] path::ValidationError), #[error("--chroot path: {0}")] - ChrootBase(#[source] path::RelativeError), + Base(#[source] path::RelativeError), #[error("--chroot must be exactly / below JAIL_BASE: {0:?}")] - ChrootDepth(PathBuf), + Depth(PathBuf), + #[error("opening chroot parent: {0}")] + Open(#[source] file::ValidationError), + #[error("removing chroot: {0}")] + Remove(#[source] dir::Error), +} + +#[derive(Debug, ThisError)] +pub enum CgroupError { #[error("--cgroup path: {0}")] - CgroupPath(#[source] path::ValidationError), + Path(#[source] path::ValidationError), #[error("--cgroup path: {0}")] - CgroupBase(#[source] path::RelativeError), + Base(#[source] path::RelativeError), #[error("--cgroup must be at least two components below /sys/fs/cgroup: {0:?}")] - CgroupDepth(PathBuf), - #[error("walking: {0}")] - Walk(#[source] file::ValidationError), - #[error("removing chroot: {0}")] - RemoveChroot(#[source] dir::Error), - #[error("removing cgroup: {0}")] - RemoveCgroup(#[source] Errno), + Depth(PathBuf), #[error("opening cgroup: {0}")] - OpenCgroup(#[source] cgroup::OpenError), + Open(#[source] cgroup::OpenError), #[error("killing cgroup procs: {0}")] - KillCgroup(#[source] cgroup::KillError), + Kill(#[source] cgroup::KillError), + #[error("removing cgroup: {0}")] + Remove(#[source] Errno), } #[derive(Args)] @@ -93,7 +101,7 @@ impl IsTool for Remove { fn run_privileged(&self) -> Self::RunT { // Cgroup first: it SIGKILLs any firecracker still alive in the leaf, so the // chroot teardown below is not yanking files out from under a live process. - remove_cgroup_under(Path::new(CGROUP_BASE), &self.args.cgroup)?; + remove_cgroup_under(Path::new(cgroup::ROOT), &self.args.cgroup)?; remove_chroot_under(&Config::get().jail_base(), &self.args.chroot)?; Ok(()) } @@ -107,20 +115,22 @@ impl IsTool for Remove { /// Recursively remove the per-VM chroot `//`, fd-relative /// after an `O_NOFOLLOW` walk from `jail_base`. Idempotent on a missing target. /// `--chroot` must be exactly `/` (one parent component, one leaf). -pub fn remove_chroot_under(jail_base: &Path, chroot: &Path) -> Result<(), Error> { - let path: &LexicalPath = chroot.try_into().map_err(Error::ChrootPath)?; - let rel = path.strip_base(jail_base).map_err(Error::ChrootBase)?; - let [exec, id] = rel.segments().collect::>()[..] else { - return Err(Error::ChrootDepth(chroot.to_path_buf())); +pub fn remove_chroot_under(jail_base: &Path, chroot: &Path) -> Result<(), ChrootError> { + let path: &LexicalPath = chroot.try_into().map_err(ChrootError::Path)?; + let rel = path.strip_base(jail_base).map_err(ChrootError::Base)?; + let (2, Some((parent, id))) = (rel.segments().count(), path.split_last()) else { + return Err(ChrootError::Depth(chroot.to_path_buf())); }; - let Some(parent) = walk(jail_base, Path::new(exec))? else { - return Ok(()); // an ancestor is already gone + let parent = match SafeDir::open(parent, OpenFlags::RDONLY) { + Ok(parent) => parent, + Err(file::ValidationError::Open(Errno::ENOENT)) => return Ok(()), + Err(e) => return Err(ChrootError::Open(e)), }; - match parent.rmdirat_recursive(Path::new(id)) { + match parent.rmdirat_recursive(id) { Ok(()) => Ok(()), Err(e) if e.errno() == Some(Errno::ENOENT) => Ok(()), - Err(e) => Err(Error::RemoveChroot(e)), + Err(e) => Err(ChrootError::Remove(e)), } } @@ -129,11 +139,11 @@ pub fn remove_chroot_under(jail_base: &Path, chroot: &Path) -> Result<(), Error> /// be at least two components below `base` (one or more parents, plus the leaf). /// Idempotent: a missing leaf is success, and a leaf that has not finished /// draining after the kill (`EBUSY`/`ENOTEMPTY`) is left for a later sweep. -pub fn remove_cgroup_under(base: &Path, cgroup: &Path) -> Result<(), Error> { - let path: &LexicalPath = cgroup.try_into().map_err(Error::CgroupPath)?; - let rel = path.strip_base(base).map_err(Error::CgroupBase)?; +pub fn remove_cgroup_under(base: &Path, cgroup: &Path) -> Result<(), CgroupError> { + let path: &LexicalPath = cgroup.try_into().map_err(CgroupError::Path)?; + let rel = path.strip_base(base).map_err(CgroupError::Base)?; if rel.segments().count() < 2 { - return Err(Error::CgroupDepth(cgroup.to_path_buf())); + return Err(CgroupError::Depth(cgroup.to_path_buf())); } // Kill any process still in the leaf cgroup before removing it. Open the leaf @@ -141,25 +151,8 @@ pub fn remove_cgroup_under(base: &Path, cgroup: &Path) -> Result<(), Error> { let cgroup = match Cgroup::open(path) { Ok(cgroup) => cgroup, Err(cgroup::OpenError::Open(file::ValidationError::Open(Errno::ENOENT))) => return Ok(()), - Err(e) => return Err(Error::OpenCgroup(e)), + Err(e) => return Err(CgroupError::Open(e)), }; - cgroup.kill_procs(KILL_TIMEOUT).map_err(Error::KillCgroup)?; - cgroup.remove().map_err(Error::RemoveCgroup) -} - -/// Open `base` and walk `parents` from it (`O_NOFOLLOW` each step). Returns -/// `Ok(None)` if `base` or any parent is already gone (`ENOENT`), so callers can -/// treat removal as idempotent. -fn walk(base: &Path, rel: &Path) -> Result, Error> { - let base_path: &LexicalPath = base.try_into().map_err(Error::CgroupPath)?; - let anchor = match SafeDir::open(base_path, OpenFlags::RDONLY) { - Ok(dir) => dir, - Err(file::ValidationError::Open(Errno::ENOENT)) => return Ok(None), - Err(e) => return Err(Error::Walk(e)), - }; - match anchor.openat(rel, OpenFlags::RDONLY, Mode::empty()) { - Ok(dir) => Ok(Some(dir)), - Err(file::ValidationError::Open(Errno::ENOENT)) => Ok(None), - Err(e) => Err(Error::Walk(e)), - } + cgroup.kill_procs(KILL_TIMEOUT).map_err(CgroupError::Kill)?; + cgroup.remove().map_err(CgroupError::Remove) } diff --git a/native/suidhelper/src/tools/dmsetup/backing.rs b/native/suidhelper/src/tools/dmsetup/backing.rs new file mode 100644 index 00000000..8aa96972 --- /dev/null +++ b/native/suidhelper/src/tools/dmsetup/backing.rs @@ -0,0 +1,47 @@ +use crate::fs::file::{self, Any, IsRegularFile, OpenFlags, SafeFile}; +use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use thiserror::Error as ThisError; + +#[derive(Debug, ThisError)] +pub enum BackingError { + #[error("backing file {path}: {source}")] + Canonicalize { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("backing file must be under {}: {}", .base.display(), .path.display())] + OutsideBase { base: PathBuf, path: PathBuf }, + #[error("backing path: {0}")] + Path(#[from] path::ValidationError), + #[error("backing file: {0}")] + Open(#[from] file::ValidationError), +} + +#[derive(Debug, Clone)] +pub struct BackingFile(Arc>); + +impl BackingFile { + pub fn open(path: &Path, base: &Path) -> Result { + let real = std::fs::canonicalize(path).map_err(|source| BackingError::Canonicalize { + path: path.to_path_buf(), + source, + })?; + if !real.starts_with(base) { + return Err(BackingError::OutsideBase { + base: base.to_path_buf(), + path: real, + }); + } + let safe: &SafePath = real.as_path().try_into()?; + let file = SafeFile::open(safe, OpenFlags::PATH)?; + Ok(Self(Arc::new(file))) + } + + pub fn file(&self) -> &SafeFile { + &self.0 + } +} diff --git a/native/suidhelper/src/tools/dmsetup/mod.rs b/native/suidhelper/src/tools/dmsetup/mod.rs index cf43ecb1..8b490781 100644 --- a/native/suidhelper/src/tools/dmsetup/mod.rs +++ b/native/suidhelper/src/tools/dmsetup/mod.rs @@ -1,15 +1,23 @@ +mod backing; mod message; mod table; use super::IsTool; -use crate::linux::dm::{self, Access, DmTable, RemoveMode}; +use crate::linux::dm::{self, RemoveMode}; +use crate::linux::loopdev::LoopControl; +use crate::linux::Access; use crate::process::binary::RootSafeBin; use crate::util::safe_dev::DmName; +pub use backing::{BackingError, BackingFile}; use clap::{Args, Subcommand}; pub use message::{parse_message, MessageError, ThinMessage}; use serde::Serialize; +use std::collections::BTreeMap; use std::path::PathBuf; -pub use table::{parse_table, TableError}; +use std::time::Duration; +pub use table::{parse_table, resolve_table, Slot, Source, TableError, TableSpec}; + +const ATTACH_TIMEOUT: Duration = Duration::from_secs(1); #[derive(Args)] pub struct DmsetupArgs { @@ -23,8 +31,8 @@ enum DmOp { name: DmName, #[arg(long)] readonly: bool, - #[arg(long, value_parser = parse_table)] - table: DmTable, + #[arg(long, value_parser = resolve_table)] + table: TableSpec, }, Remove { #[arg(long)] @@ -51,13 +59,20 @@ enum DmOp { #[derive(Serialize)] #[serde(tag = "result", rename_all = "snake_case")] pub enum DmsetupOut { - Created { device: PathBuf }, + Created { + device: PathBuf, + loops: BTreeMap, + }, Removed, Messaged, Suspended, Resumed, - Targets { output: String }, - Listed { output: String }, + Targets { + output: String, + }, + Listed { + output: String, + }, } pub struct Dmsetup { @@ -92,8 +107,21 @@ impl IsTool for Dmsetup { } else { Access::ReadWrite }; + let control = LoopControl::open()?; + let mut loops = BTreeMap::new(); + let attached = table.try_map(|backing, slot| { + let slot_access = match slot { + Slot::Origin => Access::ReadOnly, + Slot::Cow => access, + Slot::Metadata | Slot::Data => Access::ReadWrite, + }; + let device = control.attach(backing.file(), slot_access, ATTACH_TIMEOUT)?; + loops.insert(slot, device.path().as_ref().to_path_buf()); + Ok::<_, Box>(device) + })?; DmsetupOut::Created { - device: dm.create(name.as_ref(), table, access)?.path(), + device: dm.create(name.as_ref(), &attached.to_dm(), access)?.path(), + loops, } } DmOp::Remove { retry, name } => { diff --git a/native/suidhelper/src/tools/dmsetup/table.rs b/native/suidhelper/src/tools/dmsetup/table.rs index 5365bc7f..24045aa6 100644 --- a/native/suidhelper/src/tools/dmsetup/table.rs +++ b/native/suidhelper/src/tools/dmsetup/table.rs @@ -1,69 +1,285 @@ +use super::backing::{BackingError, BackingFile}; +use crate::config::Config; +use crate::fs::path::{self, IsAbsolute, SafePathBuf, StrictComponents}; use crate::linux::dm::{ - DmTable, Persistence, SnapshotTable, ThinId, ThinIdOutOfRange, ThinPoolTable, ThinTable, + DmTable, LinearTable, Persistence, SnapshotTable, ThinId, ThinIdOutOfRange, ThinPoolTable, + ThinTable, }; -use crate::util::safe_dev::{self, BlockDev, LoopDev}; +use crate::linux::loopdev::LoopDevice; +use crate::util::safe_dev::{self, BlockDev}; +use serde::Serialize; +use std::fmt; use std::path::{Path, PathBuf}; use thiserror::Error as ThisError; +pub type FilePath = SafePathBuf; + #[derive(Debug, ThisError)] pub enum TableError { #[error("unsupported dm table: {0}")] Malformed(String), #[error(transparent)] Device(#[from] safe_dev::Error), + #[error("expected a backing file, got a device: {0}")] + NotAFile(String), + #[error("backing file path {0:?}: {1}")] + FilePath(String, #[source] path::ValidationError), + #[error(transparent)] + Backing(#[from] BackingError), #[error(transparent)] ThinId(#[from] ThinIdOutOfRange), } -pub fn parse_table(s: &str) -> Result { +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Slot { + Origin, + Cow, + Metadata, + Data, +} + +#[derive(Debug, Clone)] +pub enum Source { + Device(BlockDev), + File(F), +} + +#[derive(Debug, Clone)] +pub enum TableSpec { + Linear { + sectors: u64, + origin: Source, + }, + Snapshot { + sectors: u64, + origin: Source, + cow: F, + persistence: Persistence, + chunk: u64, + }, + ThinPool { + sectors: u64, + metadata: F, + data: F, + block_sectors: u64, + low_water: u64, + }, + Thin { + sectors: u64, + pool: BlockDev, + id: ThinId, + origin: Option>, + }, +} + +impl Source { + fn try_map( + &self, + slot: Slot, + f: &mut impl FnMut(&F, Slot) -> Result, + ) -> Result, E> { + Ok(match self { + Source::Device(dev) => Source::Device(dev.clone()), + Source::File(file) => Source::File(f(file, slot)?), + }) + } +} + +impl TableSpec { + pub fn try_map( + &self, + mut f: impl FnMut(&F, Slot) -> Result, + ) -> Result, E> { + Ok(match self { + TableSpec::Linear { sectors, origin } => TableSpec::Linear { + sectors: *sectors, + origin: origin.try_map(Slot::Origin, &mut f)?, + }, + TableSpec::Snapshot { + sectors, + origin, + cow, + persistence, + chunk, + } => TableSpec::Snapshot { + sectors: *sectors, + origin: origin.try_map(Slot::Origin, &mut f)?, + cow: f(cow, Slot::Cow)?, + persistence: *persistence, + chunk: *chunk, + }, + TableSpec::ThinPool { + sectors, + metadata, + data, + block_sectors, + low_water, + } => TableSpec::ThinPool { + sectors: *sectors, + metadata: f(metadata, Slot::Metadata)?, + data: f(data, Slot::Data)?, + block_sectors: *block_sectors, + low_water: *low_water, + }, + TableSpec::Thin { + sectors, + pool, + id, + origin, + } => TableSpec::Thin { + sectors: *sectors, + pool: pool.clone(), + id: *id, + origin: origin + .as_ref() + .map(|origin| origin.try_map(Slot::Origin, &mut f)) + .transpose()?, + }, + }) + } +} + +pub trait SlotPath { + fn slot_path(&self) -> &Path; +} + +impl SlotPath for FilePath { + fn slot_path(&self) -> &Path { + (**self).as_ref() + } +} + +impl SlotPath for LoopDevice { + fn slot_path(&self) -> &Path { + self.path().as_ref() + } +} + +impl Source { + fn path(&self) -> PathBuf { + match self { + Source::Device(dev) => AsRef::::as_ref(dev).to_path_buf(), + Source::File(file) => file.slot_path().to_path_buf(), + } + } +} + +impl TableSpec { + pub fn to_dm(&self) -> DmTable { + match self { + TableSpec::Linear { sectors, origin } => DmTable::Linear(LinearTable { + sectors: *sectors, + origin: origin.path(), + }), + TableSpec::Snapshot { + sectors, + origin, + cow, + persistence, + chunk, + } => DmTable::Snapshot(SnapshotTable { + sectors: *sectors, + origin: origin.path(), + cow: cow.slot_path().to_path_buf(), + persistence: *persistence, + chunk: *chunk, + }), + TableSpec::ThinPool { + sectors, + metadata, + data, + block_sectors, + low_water, + } => DmTable::ThinPool(ThinPoolTable { + sectors: *sectors, + metadata: metadata.slot_path().to_path_buf(), + data: data.slot_path().to_path_buf(), + block_sectors: *block_sectors, + low_water: *low_water, + }), + TableSpec::Thin { + sectors, + pool, + id, + origin, + } => DmTable::Thin(ThinTable { + sectors: *sectors, + pool: AsRef::::as_ref(pool).to_path_buf(), + id: *id, + origin: origin.as_ref().map(Source::path), + }), + } + } +} + +impl fmt::Display for TableSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.to_dm().fmt(f) + } +} + +pub fn parse_table(s: &str) -> Result, TableError> { let malformed = || TableError::Malformed(s.to_string()); let number = |t: &str| t.parse::().map_err(|_| malformed()); let thin_id = |t: &str| -> Result { Ok(ThinId::new(t.parse().map_err(|_| malformed())?)?) }; - - let block = |t: &str| -> Result { - let dev: BlockDev = t.parse()?; - Ok(AsRef::::as_ref(&dev).to_path_buf()) + let is_device = |t: &str| t.starts_with("/dev/"); + let file = |t: &str| -> Result { + if is_device(t) { + return Err(TableError::NotAFile(t.to_string())); + } + PathBuf::from(t) + .try_into() + .map_err(|e| TableError::FilePath(t.to_string(), e)) }; - let looped = |t: &str| -> Result { - let dev: LoopDev = t.parse()?; - Ok(AsRef::::as_ref(&dev).to_path_buf()) + let source = |t: &str| -> Result, TableError> { + if is_device(t) { + Ok(Source::Device(t.parse()?)) + } else { + Ok(Source::File(file(t)?)) + } }; let fields: Vec<&str> = s.split_whitespace().collect(); Ok(match fields.as_slice() { + ["0", sectors, "linear", origin, "0"] => TableSpec::Linear { + sectors: number(sectors)?, + origin: source(origin)?, + }, ["0", sectors, "snapshot", origin, cow, mode, chunk] => { let persistence = match *mode { "P" => Persistence::Persistent, "N" => Persistence::Transient, _ => return Err(malformed()), }; - DmTable::Snapshot(SnapshotTable { + TableSpec::Snapshot { sectors: number(sectors)?, - origin: block(origin)?, - cow: looped(cow)?, + origin: source(origin)?, + cow: file(cow)?, persistence, chunk: number(chunk)?, - }) - } - ["0", sectors, "thin-pool", meta, data, block_sectors, low] => { - DmTable::ThinPool(ThinPoolTable { - sectors: number(sectors)?, - metadata: looped(meta)?, - data: looped(data)?, - block_sectors: number(block_sectors)?, - low_water: number(low)?, - }) - } - ["0", sectors, "thin", pool, id, origin @ ..] if origin.len() <= 1 => { - DmTable::Thin(ThinTable { - sectors: number(sectors)?, - pool: block(pool)?, - id: thin_id(id)?, - origin: origin.first().map(|o| block(o)).transpose()?, - }) + } } + ["0", sectors, "thin-pool", meta, data, block_sectors, low] => TableSpec::ThinPool { + sectors: number(sectors)?, + metadata: file(meta)?, + data: file(data)?, + block_sectors: number(block_sectors)?, + low_water: number(low)?, + }, + ["0", sectors, "thin", pool, id, origin @ ..] if origin.len() <= 1 => TableSpec::Thin { + sectors: number(sectors)?, + pool: pool.parse()?, + id: thin_id(id)?, + origin: origin.first().map(|o| source(o)).transpose()?, + }, _ => return Err(malformed()), }) } + +pub fn resolve_table(s: &str) -> Result, TableError> { + let base = Config::get().hyper_base(); + Ok(parse_table(s)?.try_map(|path, _| BackingFile::open(path.slot_path(), base))?) +} diff --git a/native/suidhelper/src/tools/losetup.rs b/native/suidhelper/src/tools/losetup.rs deleted file mode 100644 index 2cdf14af..00000000 --- a/native/suidhelper/src/tools/losetup.rs +++ /dev/null @@ -1,171 +0,0 @@ -use super::IsTool; -use crate::fs::file::{self, Any, IsRegularFile, OpenFlags, SafeFile}; -use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; -use crate::process::binary::RootSafeBin; -use crate::util::safe_dev::LoopDev; -use clap::{Args, Subcommand}; -use nix::errno::Errno; -use nix::unistd::dup; -use serde::Serialize; -use std::io; -use std::os::unix::io::AsRawFd; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use thiserror::Error as ThisError; - -#[derive(Debug, ThisError)] -pub enum Error { - #[error("backing file {path}: {source}")] - Canonicalize { - path: PathBuf, - #[source] - source: io::Error, - }, - #[error("backing file must be under {}: {path}", .base.display())] - OutsideBase { base: &'static Path, path: PathBuf }, - #[error("backing path: {0}")] - BackingPath(#[from] path::ValidationError), - #[error("backing file: {0}")] - Backing(#[from] file::ValidationError), - #[error("duplicating backing fd {path}: {errno}")] - OpenBacking { path: PathBuf, errno: Errno }, - #[error("running losetup: {0}")] - Spawn(#[source] io::Error), - #[error("losetup failed: {0}")] - Failed(String), -} - -#[derive(Args)] -pub struct LosetupArgs { - #[command(subcommand)] - op: LosetupOp, -} - -#[derive(Args)] -struct AttachArgs { - /// Attach read-write (default is read-only). - #[arg(long)] - rw: bool, - #[arg(value_parser = ok_backing_file)] - path: PathBuf, -} - -impl AttachArgs { - fn enrich_command(&self, cmd: &mut Command) { - cmd.arg("--find").arg("--show"); - if !self.rw { - cmd.arg("--read-only"); - } - cmd.arg(&self.path); - } -} - -#[derive(Subcommand)] -enum LosetupOp { - /// Attach a backing file to the next free loop device. - Attach(AttachArgs), - /// Detach a loop device. - Detach { dev: LoopDev }, - /// List loop devices as `NAME BACK-FILE` rows (for stale-device reclaim). - List, -} - -#[derive(Serialize)] -#[serde(tag = "result", rename_all = "snake_case")] -pub enum LosetupOut { - Attached { device: PathBuf }, - Detached, - Listed { output: String }, -} - -pub struct Losetup { - bin: RootSafeBin<"losetup">, - op: LosetupOp, -} - -impl Losetup { - pub fn new(bin: RootSafeBin<"losetup">, args: LosetupArgs) -> Self { - Self { bin, op: args.op } - } -} - -impl IsTool for Losetup { - type Args = LosetupArgs; - type Output = LosetupOut; - type RunT = io::Result; - - fn run_privileged(&self) -> Self::RunT { - let mut cmd = self.bin.command(); - match &self.op { - LosetupOp::Attach(args) => { - args.enrich_command(&mut cmd); - } - LosetupOp::Detach { dev } => { - let dev: &Path = dev.as_ref(); - cmd.arg("-d").arg(dev); - } - LosetupOp::List => { - cmd.args([ - "--list", - "--noheadings", - "--raw", - "--output", - "NAME,BACK-FILE", - ]); - } - } - - cmd.env_clear().output() - } - - fn parse(&self, res: Self::RunT) -> Result> { - let out = res.map_err(Error::Spawn)?; - if !out.status.success() { - return Err( - Error::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()).into(), - ); - } - - Ok(match &self.op { - LosetupOp::Attach(_) => LosetupOut::Attached { - device: PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()), - }, - LosetupOp::Detach { .. } => LosetupOut::Detached, - LosetupOp::List => LosetupOut::Listed { - output: String::from_utf8_lossy(&out.stdout).into_owned(), - }, - }) - } -} - -/// losetup backing files must live under Hyper's data root. Resolve symlinks, -/// check the real path is in-bounds, then open *that* inode and return -/// `/proc/self/fd/N`. Operating on the validated fd (not the path) closes the -/// TOCTOU window: a swap after the check can't redirect losetup elsewhere. -/// Public so the integration suite can prove the refusal/accept contract. -pub fn ok_backing_file(p: &str) -> Result { - let real = std::fs::canonicalize(p).map_err(|source| Error::Canonicalize { - path: PathBuf::from(p), - source, - })?; - - let base = crate::config::Config::get().hyper_base(); - if !real.starts_with(base) { - return Err(Error::OutsideBase { base, path: real }); - } - - // `canonicalize` already resolved every symlink and `..`, so the result is - // absolute and component-strict. Open it as a verified regular-file handle - // (O_PATH to identify; SafeFile fstats the held fd and refuses a symlink in - // any component, so a parent or final swap raced in after the check fails). - let safe: &SafePath = real.as_path().try_into()?; - let file = SafeFile::::open(safe, OpenFlags::PATH)?; - - // losetup runs as a child and reopens the *validated inode* via /proc/self/fd. - // SafeFile's fd is O_CLOEXEC (it would vanish on exec), so dup an inheritable - // copy that survives into the child; the dup is intentionally leaked, and the - // SafeFile's own fd closes on drop. - let inheritable = - dup(file.as_raw_fd()).map_err(|errno| Error::OpenBacking { path: real, errno })?; - Ok(PathBuf::from(format!("/proc/self/fd/{inheritable}"))) -} diff --git a/native/suidhelper/src/tools/mod.rs b/native/suidhelper/src/tools/mod.rs index 961a8fb3..f1069997 100644 --- a/native/suidhelper/src/tools/mod.rs +++ b/native/suidhelper/src/tools/mod.rs @@ -9,7 +9,6 @@ mod blockdev; pub mod chroot_jail; mod dmsetup; pub mod jailer; -pub mod losetup; pub mod network; pub mod thin_dump; @@ -17,9 +16,9 @@ pub use blockcopy::{Blockcopy, BlockcopyArgs}; pub use blockdev::{Blockdev, BlockdevArgs}; pub use chroot_jail::ChrootJailOp; pub use dmsetup::{ - parse_message, parse_table, Dmsetup, DmsetupArgs, MessageError, TableError, ThinMessage, + parse_message, parse_table, BackingError, BackingFile, Dmsetup, DmsetupArgs, MessageError, + Slot, Source, TableError, TableSpec, ThinMessage, }; -pub use losetup::{Losetup, LosetupArgs}; pub use network::NetworkOp; pub use thin_dump::{ThinDump, ThinDumpArgs}; @@ -80,11 +79,6 @@ pub trait IsTool { /// argument - it comes from the root-owned config (see [`Config`]). #[derive(Subcommand)] pub enum Tool { - /// Attach/detach loop devices. - Losetup { - #[command(flatten)] - args: LosetupArgs, - }, /// Create/remove device-mapper snapshot devices. Dmsetup { #[command(flatten)] @@ -125,10 +119,6 @@ impl Tool { pub fn run(self) -> Result { let config = Config::get(); match self { - Tool::Losetup { args } => { - let bin = config.losetup().map_err(|e| Error::Tool(Box::new(e)))?; - Losetup::new(bin, args).run() - } Tool::Dmsetup { args } => { let bin = config.dmsetup().map_err(|e| Error::Tool(Box::new(e)))?; Dmsetup::new(bin, args).run() diff --git a/native/suidhelper/src/util/chroot_jail.rs b/native/suidhelper/src/util/chroot_jail.rs index fd8556cf..eaec036c 100644 --- a/native/suidhelper/src/util/chroot_jail.rs +++ b/native/suidhelper/src/util/chroot_jail.rs @@ -2,12 +2,13 @@ //! Declarative builder for a VM's chroot jail contents. use crate::config::Config; +use crate::fs::dev::DeviceNumber; use crate::fs::dir::{self, SafeDir}; use crate::fs::file::{self, Any, IsBlockDevice, IsCharDevice, IsRegularFile, OpenFlags, SafeFile}; -use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponent, StrictComponents}; use crate::util::safe_dev::BlockDev; use nix::errno::Errno; -use nix::sys::stat::{makedev, Mode, SFlag}; +use nix::sys::stat::{Mode, SFlag}; use nix::unistd::{Gid, Uid}; use std::fs::File; use std::io; @@ -17,12 +18,14 @@ use thiserror::Error as ThisError; /// In-jail names — the contract the Elixir side (`Hyper.Node.FireVMM.ChrootJail`) /// mirrors. -const KERNEL_NAME: &str = "vmlinux"; -const ROOTFS_NAME: &str = "rootfs"; +const KERNEL_NAME: &StrictComponent = StrictComponent::from_static("vmlinux"); +const ROOTFS_NAME: &StrictComponent = StrictComponent::from_static("rootfs"); +const DEV: &StrictComponent = StrictComponent::from_static("dev"); +const NET: &StrictComponent = StrictComponent::from_static("net"); +const TUN: &StrictComponent = StrictComponent::from_static("tun"); /// `/dev/net/tun`'s well-known major:minor, per Linux's `Documentation/admin-guide/devices.txt`. -const TUN_MAJOR: u64 = 10; -const TUN_MINOR: u64 = 200; +const TUN_DEVICE: DeviceNumber = DeviceNumber::new(10, 200); #[derive(Debug, ThisError)] pub enum Error { @@ -146,11 +149,8 @@ impl ChrootJail { /// a symlinked component cannot redirect outside the jail. pub fn open_chroot_under(jail_base: &Path, chroot: &Path) -> Result { let path: &SafePath = chroot.try_into()?; - let rel = path.strip_base(jail_base)?; - let anchor: &SafePath = jail_base.try_into()?; - SafeDir::open(anchor, OpenFlags::RDONLY) - .and_then(|dir| dir.openat(rel.as_ref(), OpenFlags::RDONLY, Mode::empty())) - .map_err(Error::OpenChroot) + path.strip_base(jail_base)?; + SafeDir::open(path, OpenFlags::RDONLY).map_err(Error::OpenChroot) } /// Stage host file `src` into `chroot` as `vmlinux`: confine the source under @@ -163,7 +163,6 @@ pub fn stage_kernel_under( uid: u32, gid: u32, ) -> Result<(), Error> { - let kernel = Path::new(KERNEL_NAME); let (uid, gid) = (Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))); let src_canon = std::fs::canonicalize(src).map_err(|source| Error::Source { @@ -171,30 +170,27 @@ pub fn stage_kernel_under( source, })?; let src_path: &SafePath = src_canon.as_path().try_into()?; - let rel = src_path + src_path .strip_base(hyper_base) .map_err(|_| Error::OutsideBase { base: hyper_base.to_path_buf(), path: src_canon.clone(), })?; - let open_src = |source| Error::OpenSrc { - path: src_canon.clone(), - source, - }; - let base: &SafePath = hyper_base.try_into()?; - let base_dir = SafeDir::open(base, OpenFlags::RDONLY).map_err(open_src)?; - let source: SafeFile = base_dir - .openat(rel.as_ref(), OpenFlags::RDONLY, Mode::empty()) - .map_err(open_src)?; + let source = SafeFile::::open(src_path, OpenFlags::RDONLY).map_err( + |source| Error::OpenSrc { + path: src_canon.clone(), + source, + }, + )?; - match chroot.linkat(&source, kernel) { + match chroot.linkat(&source, KERNEL_NAME) { Ok(()) => source .chown(uid, gid) .map_err(|e| Error::StageKernel(e.into())), Err(Errno::EXDEV) => { let mut dest = chroot - .mkfileat(kernel, Mode::from_bits_truncate(0o600)) + .mkfileat(KERNEL_NAME, Mode::from_bits_truncate(0o600)) .map_err(|e| Error::StageKernel(e.into()))?; io::copy(&mut File::from(OwnedFd::from(source)), &mut dest).map_err(Error::Copy)?; dest.chown(uid, gid) @@ -223,11 +219,15 @@ fn make_rootfs(chroot: &SafeDir, device: &BlockDev, uid: u32, gid: u32) -> Resul let dev = SafeFile::::open(dev_path, OpenFlags::PATH) .map_err(Error::Device)?; let rdev = dev.rdev().map_err(Error::Device)?; - let name = Path::new(ROOTFS_NAME); let make = || -> Result<(), dir::Error> { - chroot.mknodat(name, SFlag::S_IFBLK, Mode::from_bits_truncate(0o600), rdev)?; + chroot.mknodat( + ROOTFS_NAME, + SFlag::S_IFBLK, + Mode::from_bits_truncate(0o600), + rdev, + )?; let node: SafeFile = - chroot.openat(name, OpenFlags::PATH, Mode::empty())?; + chroot.openat(ROOTFS_NAME, OpenFlags::PATH, Mode::empty())?; Ok(node.chown(Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))?) }; make().map_err(Error::MakeRootfs) @@ -239,22 +239,23 @@ fn make_rootfs(chroot: &SafeDir, device: &BlockDev, uid: u32, gid: u32) -> Resul /// networking is enabled; a jail built without it has no `dev` tree at all /// today (see [`ChrootJail::build_under`]). fn make_tun_node(chroot: &SafeDir, uid: u32, gid: u32) -> Result<(), dir::Error> { - let dev = Path::new("dev"); - chroot.mkdirat(dev, Mode::from_bits_truncate(0o755))?; - let dev_dir: SafeDir = chroot.openat(dev, OpenFlags::RDONLY, Mode::empty())?; + chroot.mkdirat(DEV, Mode::from_bits_truncate(0o755))?; + let dev_dir: SafeDir = chroot.openat(DEV, OpenFlags::RDONLY, Mode::empty())?; - let net = Path::new("net"); - dev_dir.mkdirat(net, Mode::from_bits_truncate(0o755))?; - let net_dir: SafeDir = dev_dir.openat(net, OpenFlags::RDONLY, Mode::empty())?; + dev_dir.mkdirat(NET, Mode::from_bits_truncate(0o755))?; + let net_dir: SafeDir = dev_dir.openat(NET, OpenFlags::RDONLY, Mode::empty())?; - let tun = Path::new("tun"); - let rdev = makedev(TUN_MAJOR, TUN_MINOR); - match net_dir.mknodat(tun, SFlag::S_IFCHR, Mode::from_bits_truncate(0o666), rdev) { + match net_dir.mknodat( + TUN, + SFlag::S_IFCHR, + Mode::from_bits_truncate(0o666), + TUN_DEVICE, + ) { Ok(()) | Err(Errno::EEXIST) => {} Err(errno) => return Err(errno.into()), } let node: SafeFile = - net_dir.openat(tun, OpenFlags::PATH, Mode::empty())?; + net_dir.openat(TUN, OpenFlags::PATH, Mode::empty())?; node.chown(Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))?; Ok(node.chmod(Mode::from_bits_truncate(0o666))?) } diff --git a/native/suidhelper/tests/config_seam.rs b/native/suidhelper/tests/config_seam.rs index f8956720..af876776 100644 --- a/native/suidhelper/tests/config_seam.rs +++ b/native/suidhelper/tests/config_seam.rs @@ -8,7 +8,7 @@ #![cfg(feature = "insecure_test_seams")] use hyper_suidhelper::config::{Config, LoadingError}; -use hyper_suidhelper::fs::path::ValidationError; +use hyper_suidhelper::fs::path::{ComponentError, ValidationError}; use hyper_suidhelper::security_gate; #[test] @@ -20,7 +20,10 @@ fn override_path_is_consulted_through_the_real_lexical_gate() { let err = Config::safe_load().expect_err("override must be consulted"); assert!( - matches!(err, LoadingError::Path(ValidationError::LooseComponents)), + matches!( + err, + LoadingError::Path(ValidationError::Component(ComponentError::ParentDir)) + ), "expected the override path to hit the real lexical gate, got {err:?}", ); } diff --git a/native/suidhelper/tests/e2e/argv.rs b/native/suidhelper/tests/e2e/argv.rs index 9dc4838c..d04b342c 100644 --- a/native/suidhelper/tests/e2e/argv.rs +++ b/native/suidhelper/tests/e2e/argv.rs @@ -32,9 +32,13 @@ fn install_fake(dir: &Path, basename: &str, record: &Path, stdout_line: &str) -> /// binaries, so the helper resolves each tool's path from config rather than a /// caller argument. fn write_root_config(dir: &Path, bins: &[(&str, &Path)]) -> PathBuf { + write_root_config_in(dir, Path::new("/srv/hyper"), bins) +} + +fn write_root_config_in(dir: &Path, work_dir: &Path, bins: &[(&str, &Path)]) -> PathBuf { let p = dir.join("config.toml"); // Every key here is a tool name, so they live under the `[tools]` table. - let mut body = String::from("work_dir = \"/srv/hyper\"\n[tools]\n"); + let mut body = format!("work_dir = \"{}\"\n[tools]\n", work_dir.display()); for (key, path) in bins { body.push_str(&format!("{key} = \"{}\"\n", path.display())); } @@ -55,12 +59,19 @@ fn dmsetup_create_snapshot_reconstructs_canonical_table_as_root() { return; } let tmp = tempfile::tempdir().unwrap(); - let rec = tmp.path().join("argv.json"); - let bin = install_fake(tmp.path(), "dmsetup", &rec, ""); - let cfg = write_root_config(tmp.path(), &[("dmsetup", &bin)]); + let base = fs::canonicalize(tmp.path()).unwrap(); + let rec = base.join("argv.json"); + let bin = install_fake(&base, "dmsetup", &rec, ""); + let cfg = write_root_config_in(&base, &base, &[("dmsetup", &bin)]); + let cow = base.join("cow.img"); + fs::File::create(&cow) + .unwrap() + .set_len(1024 * 1024) + .unwrap(); // Deliberately weird inner spacing; the helper must re-render canonically. - run( + let table = format!("0 100 snapshot /dev/loop0 {} P 8", cow.display()); + let out = run( &cfg, &[ "dmsetup", @@ -68,22 +79,28 @@ fn dmsetup_create_snapshot_reconstructs_canonical_table_as_root() { "hyper-vm1", "--readonly", "--table", - "0 100 snapshot /dev/loop0 /dev/loop1 P 8", + &table, ], ) .success(); + let json = stdout_json(&out); + let cow_loop = json["loops"]["cow"] + .as_str() + .expect("helper must report the loop it attached for the cow file"); + assert!(cow_loop.starts_with("/dev/loop"), "cow loop: {cow_loop}"); + let argv = recorded_argv(&rec); assert_eq!( argv, vec![ - "create", - "hyper-vm1", - "--readonly", - "--table", - "0 100 snapshot /dev/loop0 /dev/loop1 P 8", // canonical single-spaced + "create".to_string(), + "hyper-vm1".to_string(), + "--readonly".to_string(), + "--table".to_string(), + format!("0 100 snapshot /dev/loop0 {cow_loop} P 8"), ], - "helper did not reconstruct the canonical table", + "helper did not substitute the attached loop into the canonical table", ); } @@ -173,41 +190,6 @@ fn dmsetup_ls_argv_and_parse_as_root() { assert_eq!(json["output"], "hyper-thinpool\nhyper-rw-abc\n"); } -#[test] -fn losetup_list_argv_and_parse_as_root() { - if !is_root() { - eprintln!("SKIP losetup_list: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let rec = tmp.path().join("argv.json"); - let bin = install_fake( - tmp.path(), - "losetup", - &rec, - "/dev/loop0 /srv/hyper/scratch/thinpool.meta", - ); - let cfg = write_root_config(tmp.path(), &[("losetup", &bin)]); - - let out = run(&cfg, &["losetup", "list"]).success(); - assert_eq!( - recorded_argv(&rec), - vec![ - "--list", - "--noheadings", - "--raw", - "--output", - "NAME,BACK-FILE" - ] - ); - let json = stdout_json(&out); - assert_eq!(json["result"], "listed"); - assert_eq!( - json["output"], - "/dev/loop0 /srv/hyper/scratch/thinpool.meta\n" - ); -} - #[test] fn dmsetup_rejects_configured_bin_with_wrong_basename_as_root() { if !is_root() { diff --git a/native/suidhelper/tests/e2e/losetup.rs b/native/suidhelper/tests/e2e/losetup.rs deleted file mode 100644 index 33a4e2ca..00000000 --- a/native/suidhelper/tests/e2e/losetup.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! L4 for `losetup attach`/`detach`: prove the helper hands the child a -//! validated `/proc/self/fd/N` handle (never the caller's path string), that -//! the handle names the caller's real inode, that the rw toggle and detach -//! argv render exactly, and that out-of-base or malformed operands are refused -//! BEFORE the tool binary ever runs. -#![cfg(feature = "insecure_test_seams")] - -use predicates::str::contains; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; - -mod support; -use support::{is_root, run, stdout_json}; - -/// A root-owned fake `losetup` that records its argv (minus argv[0]) into -/// `record` as a JSON array, resolves its LAST argument with readlink into -/// `resolved` (proving an fd handle points at the real backing inode), and -/// prints `stdout_line` for the helper's parser. Absolute /usr/bin/readlink -/// because the helper spawns the child with a cleared environment (no PATH). -fn install_fake_losetup(dir: &Path, record: &Path, resolved: &Path, stdout_line: &str) -> PathBuf { - let path = dir.join("losetup"); - let script = format!( - r#"#!/bin/sh -printf '%s' "$( - printf '[' - sep='' - for a in "$@"; do printf '%s"%s"' "$sep" "$a"; sep=','; done - printf ']' -)" > '{record}' -for a in "$@"; do last=$a; done -/usr/bin/readlink "$last" > '{resolved}' 2>/dev/null || true -printf '{stdout_line}\n' -"#, - record = record.display(), - resolved = resolved.display(), - ); - fs::write(&path, script).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); - path // root-owned because these tests run as root -} - -/// A root-owned fake `losetup` that fails with a message, to prove the -/// helper's non-zero-exit arm surfaces the child's stderr. -fn install_failing_losetup(dir: &Path) -> PathBuf { - let path = dir.join("losetup"); - fs::write( - &path, - r"#!/bin/sh -echo 'boom: no free loop device' >&2 -exit 1 -", - ) - .unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); - path -} - -/// Root-owned config: work_dir is the REAL (canonicalized) tempdir so a -/// backing file inside it passes confinement, and losetup points at the fake. -fn write_root_config(dir: &Path, work_dir: &Path, losetup_bin: &Path) -> PathBuf { - let p = dir.join("config.toml"); - let body = format!( - r#"work_dir = "{work_dir}" -[tools] -losetup = "{losetup}" -"#, - work_dir = work_dir.display(), - losetup = losetup_bin.display(), - ); - fs::write(&p, body).unwrap(); - fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap(); - p -} - -fn recorded_argv(record: &Path) -> Vec { - let body = fs::read_to_string(record).expect("fake recorded argv"); - serde_json::from_str(&body).expect("argv json") -} - -#[test] -fn losetup_attach_passes_validated_fd_readonly_as_root() { - if !is_root() { - eprintln!("SKIP losetup_attach ro: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = fs::canonicalize(tmp.path()).unwrap(); - let rec = base.join("argv.json"); - let resolved = base.join("resolved.txt"); - let bin = install_fake_losetup(&base, &rec, &resolved, "/dev/loop9"); - let backing = base.join("disk.img"); - fs::write(&backing, b"data").unwrap(); - let cfg = write_root_config(&base, &base, &bin); - - let out = run(&cfg, &["losetup", "attach", backing.to_str().unwrap()]).success(); - - let argv = recorded_argv(&rec); - assert_eq!(argv.len(), 4, "argv: {argv:?}"); - assert_eq!(&argv[..3], ["--find", "--show", "--read-only"]); - assert!( - argv[3].starts_with("/proc/self/fd/"), - "backing operand must be an fd handle, got {}", - argv[3], - ); - // The child-visible fd names the validated inode — the caller's real file. - assert_eq!( - fs::read_to_string(&resolved).unwrap().trim(), - backing.to_str().unwrap(), - ); - - let json = stdout_json(&out); - assert_eq!(json["result"], "attached"); - assert_eq!(json["device"], "/dev/loop9"); -} - -#[test] -fn losetup_attach_rw_omits_read_only_as_root() { - if !is_root() { - eprintln!("SKIP losetup_attach rw: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = fs::canonicalize(tmp.path()).unwrap(); - let rec = base.join("argv.json"); - let resolved = base.join("resolved.txt"); - let bin = install_fake_losetup(&base, &rec, &resolved, "/dev/loop9"); - let backing = base.join("disk.img"); - fs::write(&backing, b"data").unwrap(); - let cfg = write_root_config(&base, &base, &bin); - - run( - &cfg, - &["losetup", "attach", "--rw", backing.to_str().unwrap()], - ) - .success(); - - let argv = recorded_argv(&rec); - assert_eq!( - argv.len(), - 3, - "rw attach must not pass --read-only: {argv:?}" - ); - assert_eq!(&argv[..2], ["--find", "--show"]); - assert!(argv[2].starts_with("/proc/self/fd/")); -} - -#[test] -fn losetup_attach_refuses_out_of_base_before_running_tool_as_root() { - if !is_root() { - eprintln!("SKIP losetup_attach refusal: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - let base = fs::canonicalize(tmp.path()).unwrap(); - let rec = base.join("argv.json"); - let resolved = base.join("resolved.txt"); - let bin = install_fake_losetup(&base, &rec, &resolved, "/dev/loop9"); - let cfg = write_root_config(&base, &base, &bin); - - let escapee = outside.path().join("escape.img"); - fs::write(&escapee, b"x").unwrap(); - - run(&cfg, &["losetup", "attach", escapee.to_str().unwrap()]) - .failure() - .stderr(contains("must be under")); - // Validation precedes execution: the tool must never have run. - assert!(!rec.exists(), "fake losetup ran despite refused operand"); -} - -#[test] -fn losetup_detach_argv_and_parse_as_root() { - if !is_root() { - eprintln!("SKIP losetup_detach: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = fs::canonicalize(tmp.path()).unwrap(); - let rec = base.join("argv.json"); - let resolved = base.join("resolved.txt"); - let bin = install_fake_losetup(&base, &rec, &resolved, ""); - let cfg = write_root_config(&base, &base, &bin); - - let out = run(&cfg, &["losetup", "detach", "/dev/loop7"]).success(); - assert_eq!(recorded_argv(&rec), vec!["-d", "/dev/loop7"]); - let json = stdout_json(&out); - assert_eq!(json["result"], "detached"); -} - -#[test] -fn losetup_detach_refuses_non_loop_operands_as_root() { - if !is_root() { - eprintln!("SKIP losetup_detach refusal: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = fs::canonicalize(tmp.path()).unwrap(); - let rec = base.join("argv.json"); - let resolved = base.join("resolved.txt"); - let bin = install_fake_losetup(&base, &rec, &resolved, ""); - let cfg = write_root_config(&base, &base, &bin); - - // System storage and a path trick through a loop-looking prefix: both - // must be refused at parse time, before the tool binary runs. - for bad in ["/dev/sda", "/dev/loop0/../sda", "/dev/loop", "/dev/loopX"] { - run(&cfg, &["losetup", "detach", bad]).failure(); - assert!(!rec.exists(), "fake losetup ran for refused operand {bad}"); - } -} - -#[test] -fn losetup_child_failure_surfaces_its_stderr_as_root() { - if !is_root() { - eprintln!("SKIP losetup failure arm: needs root"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = fs::canonicalize(tmp.path()).unwrap(); - let bin = install_failing_losetup(&base); - let backing = base.join("disk.img"); - fs::write(&backing, b"data").unwrap(); - let cfg = write_root_config(&base, &base, &bin); - - run(&cfg, &["losetup", "attach", backing.to_str().unwrap()]) - .failure() - .stderr(contains("losetup failed: boom: no free loop device")); -} diff --git a/native/suidhelper/tests/tools/chroot_jail_grant_api.rs b/native/suidhelper/tests/tools/chroot_jail_grant_api.rs index 9f33a979..9865812c 100644 --- a/native/suidhelper/tests/tools/chroot_jail_grant_api.rs +++ b/native/suidhelper/tests/tools/chroot_jail_grant_api.rs @@ -21,7 +21,7 @@ //! its parent `root` dir is opened for the caller's group to traverse //! (chgrp'd to the caller, chmod'd 0710) so the node can reach the socket. -use hyper_suidhelper::fs::path::{RelativeError, ValidationError}; +use hyper_suidhelper::fs::path::{ComponentError, RelativeError, ValidationError}; use hyper_suidhelper::tools::chroot_jail::grant_api::{ grant_api_under, ApiSocket, Error, GrantError, GrantOut, }; @@ -60,7 +60,10 @@ fn dotdot_traversal_is_rejected_at_parse() { let bad = format!("{}/exec/../id/root/api.socket", tmp.path().display()); let err = ApiSocket::from_str(&bad).unwrap_err(); assert!( - matches!(err, Error::SocketPath(ValidationError::LooseComponents)), + matches!( + err, + Error::SocketPath(ValidationError::Component(ComponentError::ParentDir)) + ), "got {err:?}", ); } diff --git a/native/suidhelper/tests/tools/chroot_jail_grant_vsock.rs b/native/suidhelper/tests/tools/chroot_jail_grant_vsock.rs index cfe1f5db..66d85bf3 100644 --- a/native/suidhelper/tests/tools/chroot_jail_grant_vsock.rs +++ b/native/suidhelper/tests/tools/chroot_jail_grant_vsock.rs @@ -22,7 +22,7 @@ //! its parent `root` dir is opened for the caller's group to traverse //! (chgrp'd to the caller, chmod'd 0710) so the node can reach the socket. -use hyper_suidhelper::fs::path::{RelativeError, ValidationError}; +use hyper_suidhelper::fs::path::{ComponentError, RelativeError, ValidationError}; use hyper_suidhelper::tools::chroot_jail::grant_vsock::{ grant_vsock_under, Error, GrantError, GrantOut, VsockSocket, }; @@ -71,7 +71,10 @@ fn dotdot_traversal_is_rejected_at_parse() { let bad = format!("{}/exec/../id/root/vsock.sock", tmp.path().display()); let err = VsockSocket::from_str(&bad).unwrap_err(); assert!( - matches!(err, Error::SocketPath(ValidationError::LooseComponents)), + matches!( + err, + Error::SocketPath(ValidationError::Component(ComponentError::ParentDir)) + ), "got {err:?}", ); } diff --git a/native/suidhelper/tests/tools/chroot_jail_remove.rs b/native/suidhelper/tests/tools/chroot_jail_remove.rs index 86f946c4..21699534 100644 --- a/native/suidhelper/tests/tools/chroot_jail_remove.rs +++ b/native/suidhelper/tests/tools/chroot_jail_remove.rs @@ -11,9 +11,9 @@ //! * removal — a real `/` tree is gone afterward, and its siblings //! and the cgroup parent survive. -use hyper_suidhelper::fs::path::ValidationError; +use hyper_suidhelper::fs::path::{ComponentError, ValidationError}; use hyper_suidhelper::tools::chroot_jail::remove::{ - remove_cgroup_under, remove_chroot_under, Error, + remove_cgroup_under, remove_chroot_under, CgroupError, ChrootError, }; use proptest::prelude::*; use std::fs; @@ -28,7 +28,7 @@ fn chroot_too_shallow_is_depth_error() { let jail = tmp.path(); let chroot = jail.join("only-one"); let err = remove_chroot_under(jail, &chroot).unwrap_err(); - assert!(matches!(err, Error::ChrootDepth(_)), "got {err:?}"); + assert!(matches!(err, ChrootError::Depth(_)), "got {err:?}"); } // Three components below the base is too deep. @@ -38,7 +38,7 @@ fn chroot_too_deep_is_depth_error() { let jail = tmp.path(); let chroot = jail.join("exec").join("id").join("extra"); let err = remove_chroot_under(jail, &chroot).unwrap_err(); - assert!(matches!(err, Error::ChrootDepth(_)), "got {err:?}"); + assert!(matches!(err, ChrootError::Depth(_)), "got {err:?}"); } // `--cgroup` must be at least two components below its base: a direct child is @@ -49,7 +49,7 @@ fn cgroup_too_shallow_is_depth_error() { let base = tmp.path(); let cgroup = base.join("leaf"); let err = remove_cgroup_under(base, &cgroup).unwrap_err(); - assert!(matches!(err, Error::CgroupDepth(_)), "got {err:?}"); + assert!(matches!(err, CgroupError::Depth(_)), "got {err:?}"); } // A `..` component is rejected by the real SafePath gate, before any FS access. @@ -60,7 +60,10 @@ fn chroot_with_dotdot_is_rejected() { let chroot = PathBuf::from(format!("{}/exec/../escape", jail.display())); let err = remove_chroot_under(jail, &chroot).unwrap_err(); assert!( - matches!(err, Error::ChrootPath(ValidationError::LooseComponents)), + matches!( + err, + ChrootError::Path(ValidationError::Component(ComponentError::ParentDir)) + ), "got {err:?}", ); } @@ -71,7 +74,7 @@ fn relative_chroot_is_rejected() { let tmp = tempfile::tempdir().unwrap(); let err = remove_chroot_under(tmp.path(), Path::new("exec/id")).unwrap_err(); assert!( - matches!(err, Error::ChrootPath(ValidationError::NotAbsolute)), + matches!(err, ChrootError::Path(ValidationError::NotAbsolute)), "got {err:?}", ); } @@ -169,7 +172,7 @@ proptest! { if comps.len() == 2 { prop_assert!(res.is_ok(), "depth 2 must be Ok, got {res:?}"); } else { - prop_assert!(matches!(res, Err(Error::ChrootDepth(_))), "got {res:?}"); + prop_assert!(matches!(res, Err(ChrootError::Depth(_))), "got {res:?}"); } } @@ -187,7 +190,7 @@ proptest! { } let res = remove_cgroup_under(base, &cgroup); if comps.len() == 1 { - prop_assert!(matches!(res, Err(Error::CgroupDepth(_))), "got {res:?}"); + prop_assert!(matches!(res, Err(CgroupError::Depth(_))), "got {res:?}"); } else { prop_assert!(res.is_ok(), "depth >= 2 must be Ok, got {res:?}"); } diff --git a/native/suidhelper/tests/tools/dmsetup_backing.rs b/native/suidhelper/tests/tools/dmsetup_backing.rs new file mode 100644 index 00000000..f1e13cb0 --- /dev/null +++ b/native/suidhelper/tests/tools/dmsetup_backing.rs @@ -0,0 +1,99 @@ +//! Contracts of `BackingFile::open`, the confinement gate for every file a dm +//! table names. Laws: +//! * a path that does not canonicalize (absent file) is refused as +//! `Canonicalize`, never accepted; +//! * an existing path whose resolved real location is outside the base is +//! ALWAYS refused as `OutsideBase`, regardless of the spelling used to +//! reach it (direct, symlink, `..` traversal, in-base symlink); +//! * an in-base regular file is accepted as a handle to exactly that inode; +//! * anything in-base that is not a regular file is refused. + +use hyper_suidhelper::tools::{BackingError, BackingFile}; +use nix::sys::stat::fstat; +use proptest::prelude::*; +use std::os::unix::fs::MetadataExt; +use std::os::unix::io::AsRawFd; +use std::path::PathBuf; +use tempfile::TempDir; + +fn real_tempdir() -> (TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let real = std::fs::canonicalize(tmp.path()).unwrap(); + (tmp, real) +} + +proptest! { + #[test] + fn any_existing_file_outside_base_is_refused( + name in "[A-Za-z0-9][A-Za-z0-9._-]{0,31}", + ) { + let (_base_dir, base) = real_tempdir(); + let (_outside_dir, outside) = real_tempdir(); + let p = outside.join(&name); + std::fs::write(&p, b"x").unwrap(); + + let err = BackingFile::open(&p, &base).expect_err("out-of-base backing file accepted"); + prop_assert!(matches!(err, BackingError::OutsideBase { .. }), "got {err:?}"); + } +} + +#[test] +fn absent_path_is_refused_as_canonicalize() { + let (_base_dir, base) = real_tempdir(); + + let err = BackingFile::open(&base.join("nope.img"), &base).expect_err("absent file accepted"); + assert!( + matches!(err, BackingError::Canonicalize { .. }), + "got {err:?}" + ); +} + +#[test] +fn refusal_is_decided_on_the_resolved_path() { + let (_base_dir, base) = real_tempdir(); + let (_outside_dir, outside) = real_tempdir(); + let target = outside.join("target.img"); + std::fs::write(&target, b"x").unwrap(); + + let outer_link = outside.join("link.img"); + std::os::unix::fs::symlink(&target, &outer_link).unwrap(); + let inner_link = base.join("innocent.img"); + std::os::unix::fs::symlink(&target, &inner_link).unwrap(); + std::fs::create_dir(base.join("sub")).unwrap(); + let dotdot = base + .join("sub/../..") + .join(outside.file_name().unwrap()) + .join("target.img"); + + for spelling in [outer_link, inner_link, dotdot] { + let err = BackingFile::open(&spelling, &base).expect_err("out-of-base spelling accepted"); + assert!( + matches!(err, BackingError::OutsideBase { .. }), + "{}: got {err:?}", + spelling.display(), + ); + } +} + +#[test] +fn in_base_file_is_accepted_as_that_inode() { + let (_base_dir, base) = real_tempdir(); + let backing = base.join("disk.img"); + std::fs::write(&backing, b"data").unwrap(); + + let got = BackingFile::open(&backing, &base).expect("in-base regular file refused"); + + let held = fstat(got.file().as_raw_fd()).unwrap(); + let on_disk = std::fs::metadata(&backing).unwrap(); + assert_eq!((held.st_dev, held.st_ino), (on_disk.dev(), on_disk.ino())); +} + +#[test] +fn directory_under_base_is_refused() { + let (_base_dir, base) = real_tempdir(); + let dir = base.join("subdir"); + std::fs::create_dir(&dir).unwrap(); + + let err = BackingFile::open(&dir, &base).expect_err("directory accepted as backing file"); + assert!(matches!(err, BackingError::Open(_)), "got {err:?}"); +} diff --git a/native/suidhelper/tests/tools/dmsetup_parsers.rs b/native/suidhelper/tests/tools/dmsetup_parsers.rs index 3b3f92f9..b05419b3 100644 --- a/native/suidhelper/tests/tools/dmsetup_parsers.rs +++ b/native/suidhelper/tests/tools/dmsetup_parsers.rs @@ -1,12 +1,13 @@ //! `DmTable`/`ThinMessage` are reconstructed from the caller's string and then //! re-rendered, so dmsetup only ever sees a table/message we rebuilt. The accept //! set must round-trip to a canonical single-spaced form; the reject set covers -//! non-whitelisted targets, arbitrary (non loop / non hyper-) devices, and -//! malformed arity. These are the contract that keeps dmsetup off arbitrary -//! storage. +//! non-whitelisted targets, arbitrary (non loop / non hyper-) devices, devices +//! named where only a backing file may go, non-strict file paths, and malformed +//! arity. These are the contract that keeps dmsetup off arbitrary storage. use hyper_suidhelper::linux::dm::ThinId; use hyper_suidhelper::tools::{parse_message, parse_table, ThinMessage}; +use proptest::prelude::*; fn id(n: u32) -> ThinId { ThinId::new(n).unwrap() @@ -15,10 +16,14 @@ fn id(n: u32) -> ThinId { #[test] fn accepts_canonical_tables_and_round_trips() { for s in [ - "0 100 snapshot /dev/loop0 /dev/loop1 P 8", - "0 100 thin-pool /dev/loop0 /dev/loop1 128 1024", + "0 100 linear /srv/hyper/layers/base.img 0", + "0 100 linear /dev/mapper/hyper-img 0", + "0 100 snapshot /srv/hyper/layers/base.img /srv/hyper/layers/delta.img P 8", + "0 100 snapshot /dev/mapper/hyper-img /srv/hyper/scratch/cow.img P 8", + "0 100 thin-pool /srv/hyper/scratch/meta /srv/hyper/scratch/data 128 1024", "0 100 thin /dev/mapper/hyper-pool 0", "0 100 thin /dev/mapper/hyper-pool 0 /dev/mapper/hyper-orig", + "0 100 thin /dev/mapper/hyper-pool 0 /srv/hyper/layers/base.img", "0 100 thin /dev/mapper/hyper-pool 16777215", ] { let t = parse_table(s).unwrap_or_else(|_| panic!("rejected {s:?}")); @@ -28,8 +33,8 @@ fn accepts_canonical_tables_and_round_trips() { #[test] fn normalizes_inner_whitespace_on_render() { - let weird = "0 100 snapshot /dev/loop0 /dev/loop1 P 8"; - let canonical = "0 100 snapshot /dev/loop0 /dev/loop1 P 8"; + let weird = "0 100 snapshot /dev/mapper/hyper-img /srv/hyper/cow.img P 8"; + let canonical = "0 100 snapshot /dev/mapper/hyper-img /srv/hyper/cow.img P 8"; let t = parse_table(weird).expect("weird spacing must still parse"); assert_eq!(t.to_string(), canonical); } @@ -37,25 +42,44 @@ fn normalizes_inner_whitespace_on_render() { #[test] fn rejects_non_whitelisted_targets_and_devices() { for bad in [ - "0 100 linear /dev/loop0 0", // target not allowed - "0 100 crypt /dev/loop0 /dev/loop1", // target not allowed - "0 100 snapshot /dev/sda /dev/loop1 P 8", // origin = arbitrary storage - "0 100 snapshot /dev/loop0 /dev/sda P 8", // cow = arbitrary storage - "0 100 snapshot /dev/loop0 /dev/loop1 X 8", // bad persistence flag - "1 100 snapshot /dev/loop0 /dev/loop1 P 8", // start != 0 - "0 100 snapshot /dev/loop0 /dev/loop1 P", // too few fields - "0 100 thin-pool /dev/sda /dev/loop1 128 1024", // meta = arbitrary - "0 100 thin /dev/sda 0", // pool = arbitrary - "0 100 thin /dev/mapper/hyper-pool 16777216", // id past 24 bits + "0 100 crypt /dev/loop0 /srv/hyper/a", // target not allowed + "0 100 linear /dev/sda 0", // origin = arbitrary storage + "0 100 linear /srv/hyper/a 8", // nonzero offset + "0 100 snapshot /dev/sda /srv/hyper/a P 8", // origin = arbitrary storage + "0 100 snapshot /dev/loop0 /dev/loop1 P 8", // cow = device, not a file + "0 100 snapshot /dev/loop0 srv/hyper/a P 8", // cow = relative path + "0 100 snapshot /dev/loop0 /srv/hyper/../a P 8", // cow = `..` component + "0 100 snapshot /dev/loop0 /srv/hyper/a X 8", // bad persistence flag + "1 100 snapshot /dev/loop0 /srv/hyper/a P 8", // start != 0 + "0 100 snapshot /dev/loop0 /srv/hyper/a P", // too few fields + "0 100 thin-pool /dev/loop0 /srv/hyper/d 128 1024", // meta = device + "0 100 thin-pool /srv/hyper/m /dev/loop1 128 1024", // data = device + "0 100 thin /dev/sda 0", // pool = arbitrary + "0 100 thin /srv/hyper/pool 0", // pool = file + "0 100 thin /dev/mapper/hyper-pool 16777216", // id past 24 bits "0 100 thin /dev/mapper/hyper-pool 0 /dev/sda", // origin = arbitrary "0 100 thin /dev/mapper/hyper-pool 0 /dev/loop0 /dev/loop1", // two origins - "", // empty - "garbage", // junk + "", // empty + "garbage", // junk ] { assert!(parse_table(bad).is_err(), "parse_table accepted {bad:?}"); } } +proptest! { + #[test] + fn a_device_is_never_accepted_as_a_backing_file(rest in "[A-Za-z0-9._/-]{1,32}") { + let dev = format!("/dev/{rest}"); + for table in [ + format!("0 8 snapshot /dev/mapper/hyper-img {dev} P 8"), + format!("0 8 thin-pool {dev} /srv/hyper/data 128 0"), + format!("0 8 thin-pool /srv/hyper/meta {dev} 128 0"), + ] { + prop_assert!(parse_table(&table).is_err(), "accepted {table:?}"); + } + } +} + #[test] fn thinmessage_accepts_whitelisted_and_normalizes() { for (s, want) in [ diff --git a/native/suidhelper/tests/tools/losetup_backing.rs b/native/suidhelper/tests/tools/losetup_backing.rs deleted file mode 100644 index 2b85415a..00000000 --- a/native/suidhelper/tests/tools/losetup_backing.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Contracts of `ok_backing_file`, the losetup backing-file confinement gate. -//! Laws: -//! * a path that does not canonicalize (absent file) is refused as -//! `Canonicalize`, never accepted; -//! * an existing path whose *resolved* real location is outside the -//! configured data root is ALWAYS refused as `OutsideBase`, regardless of -//! the spelling used to reach it (direct, symlink, `..` traversal); -//! * (as-root, Task 2) an in-base regular file is accepted as an -//! inheritable `/proc/self/fd/N` handle naming exactly the validated inode. -#![cfg(feature = "insecure_test_seams")] - -use hyper_suidhelper::security_gate; -use hyper_suidhelper::tools::losetup::{ok_backing_file, Error}; -use proptest::prelude::*; -use std::os::unix::fs::PermissionsExt; - -// An absolute, lexically-strict path that never exists: `safe_load` sees -// ENOENT and falls back to the built-in defaults (hyper_base = /srv/hyper), -// making the base deterministic even on hosts that have a real -// /etc/hyper/config.toml. -const ABSENT_CONFIG: &str = "/hyper-suidhelper-test-absent/config.toml"; - -fn init_default_config() { - std::env::set_var("HYPER_SETUIDHELPER_IS_INSECURE_MODE", "1"); - std::env::set_var("HYPER_SETUIDHELPER_CONFIG_PATH", ABSENT_CONFIG); - security_gate::init(); -} - -proptest! { - /// Any existing regular file outside the data root is refused as - /// `OutsideBase`, whatever its name. - #[test] - fn any_existing_file_outside_base_is_refused( - name in "[A-Za-z0-9][A-Za-z0-9._-]{0,31}", - ) { - init_default_config(); - let tmp = tempfile::tempdir().unwrap(); - let p = tmp.path().join(&name); - std::fs::write(&p, b"x").unwrap(); - - let err = ok_backing_file(p.to_str().unwrap()) - .expect_err("out-of-base backing file accepted"); - prop_assert!(matches!(err, Error::OutsideBase { .. }), "got {err:?}"); - } -} - -#[test] -fn absent_path_is_refused_as_canonicalize() { - init_default_config(); - let tmp = tempfile::tempdir().unwrap(); - let absent = tmp.path().join("nope.img"); - - let err = ok_backing_file(absent.to_str().unwrap()).expect_err("absent backing file accepted"); - assert!(matches!(err, Error::Canonicalize { .. }), "got {err:?}"); -} - -#[test] -fn refusal_is_decided_on_the_resolved_path() { - init_default_config(); - let tmp = tempfile::tempdir().unwrap(); - let target = tmp.path().join("target.img"); - std::fs::write(&target, b"x").unwrap(); - - // A symlink and a `..` traversal both resolve to the same out-of-base - // inode; the resolved path (not the spelling) must decide. - let link = tmp.path().join("link.img"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - std::fs::create_dir(tmp.path().join("sub")).unwrap(); - let dotdot = format!("{}/sub/../target.img", tmp.path().display()); - - for spelling in [link.to_str().unwrap(), &dotdot] { - let err = ok_backing_file(spelling).expect_err("out-of-base spelling accepted"); - assert!( - matches!(err, Error::OutsideBase { .. }), - "{spelling}: got {err:?}", - ); - } -} - -fn is_root() -> bool { - nix::unistd::geteuid().is_root() -} - -/// Point the config seam at a root-owned config whose work_dir is the (real, -/// canonicalized) tempdir, so in-base acceptance is testable. Root-only: the -/// config file must be root-owned to pass `SafeFile`'s owner check. -fn init_config_with_base(tmp: &std::path::Path) -> std::path::PathBuf { - let base = std::fs::canonicalize(tmp).unwrap(); - let cfg = tmp.join("config.toml"); - std::fs::write(&cfg, format!(r#"work_dir = "{}""#, base.display()) + "\n").unwrap(); - std::fs::set_permissions(&cfg, std::fs::Permissions::from_mode(0o644)).unwrap(); - std::env::set_var("HYPER_SETUIDHELPER_IS_INSECURE_MODE", "1"); - std::env::set_var("HYPER_SETUIDHELPER_CONFIG_PATH", &cfg); - security_gate::init(); - base -} - -#[test] -fn in_base_file_yields_inheritable_fd_to_same_inode_as_root() { - if !is_root() { - eprintln!("SKIP in_base_file accept: needs root to own the config file"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = init_config_with_base(tmp.path()); - let backing = base.join("disk.img"); - std::fs::write(&backing, b"data").unwrap(); - - let got = - ok_backing_file(backing.to_str().unwrap()).expect("in-base regular file must be accepted"); - - // Round-trip: the returned handle names exactly the validated inode. - let fd: i32 = got - .to_str() - .unwrap() - .strip_prefix("/proc/self/fd/") - .expect("must return a /proc/self/fd/N handle") - .parse() - .unwrap(); - assert_eq!(std::fs::read_link(&got).unwrap(), backing); - - // The whole point of the dup: the fd must survive exec into the losetup - // child, i.e. CLOEXEC must be OFF. (raw libc keeps this independent of - // nix's fcntl fd-type churn across versions) - let flags = unsafe { nix::libc::fcntl(fd, nix::libc::F_GETFD) }; - assert!(flags >= 0, "F_GETFD failed on the returned fd"); - assert_eq!( - flags & nix::libc::FD_CLOEXEC, - 0, - "backing fd must be inheritable across exec", - ); -} - -#[test] -fn directory_under_base_is_refused_as_root() { - if !is_root() { - eprintln!("SKIP directory refusal: needs root to own the config file"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let base = init_config_with_base(tmp.path()); - let dir = base.join("subdir"); - std::fs::create_dir(&dir).unwrap(); - - let err = ok_backing_file(dir.to_str().unwrap()) - .expect_err("a directory must never be a backing file"); - assert!(matches!(err, Error::Backing(_)), "got {err:?}"); -} - -#[test] -fn in_base_symlink_to_outside_is_refused_as_root() { - if !is_root() { - eprintln!("SKIP symlink-escape refusal: needs root to own the config file"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - let base = init_config_with_base(tmp.path()); - - // The link LIVES under base but its target does not — the resolved real - // path must decide, so this in-base-looking name is refused. - let target = outside.path().join("escape.img"); - std::fs::write(&target, b"x").unwrap(); - let link = base.join("innocent.img"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - let err = ok_backing_file(link.to_str().unwrap()) - .expect_err("in-base symlink to an out-of-base target accepted"); - assert!(matches!(err, Error::OutsideBase { .. }), "got {err:?}"); -} diff --git a/native/suidhelper/tests/util/confinement.rs b/native/suidhelper/tests/util/confinement.rs index 067bae57..bde2e335 100644 --- a/native/suidhelper/tests/util/confinement.rs +++ b/native/suidhelper/tests/util/confinement.rs @@ -8,7 +8,7 @@ use hyper_suidhelper::fs::file::{ Any, IsBlockDevice, IsDirectory, IsRegularFile, OpenFlags, OwnerOnlyWritable, RootOwner, SafeFile, ValidationError, }; -use hyper_suidhelper::fs::path::{IsAbsolute, SafePathBuf, StrictComponents}; +use hyper_suidhelper::fs::path::{IsAbsolute, SafePathBuf, StrictComponent, StrictComponents}; use nix::dir::Type; use nix::errno::Errno; use nix::sys::stat::Mode; @@ -47,7 +47,9 @@ fn rmdirat_recursive_does_not_follow_symlinks_out_of_tree() { symlink(&sentinel, tree.join("escape")).unwrap(); let anchor = SafeDir::open(&safe(tmp.path()), OpenFlags::RDONLY).unwrap(); - anchor.rmdirat_recursive(Path::new("tree")).unwrap(); + anchor + .rmdirat_recursive(StrictComponent::from_static("tree")) + .unwrap(); assert!(!tree.exists(), "tree must be gone"); assert!(sentinel.exists(), "sentinel dir must survive"); @@ -99,7 +101,9 @@ fn rmdirat_recursive_clears_nested_tree() { fs::write(tree.join("a/top.txt"), b"y").unwrap(); let anchor = SafeDir::open(&safe(tmp.path()), OpenFlags::RDONLY).unwrap(); - anchor.rmdirat_recursive(Path::new("tree")).unwrap(); + anchor + .rmdirat_recursive(StrictComponent::from_static("tree")) + .unwrap(); assert!(!tree.exists()); } @@ -153,7 +157,12 @@ fn entries_reports_each_child_with_its_own_type() { let _sock = UnixListener::bind(tmp.path().join("sock")).unwrap(); let dir = SafeDir::open(&safe(tmp.path()), OpenFlags::RDONLY).unwrap(); - let got: BTreeMap = dir.entries().unwrap().collect::>().unwrap(); + let got: BTreeMap = dir + .entries() + .unwrap() + .map(|entry| entry.map(|(name, ty)| (name.as_os_str().to_os_string(), ty))) + .collect::>() + .unwrap(); let want = BTreeMap::from([ (OsString::from("dir"), Type::Directory), diff --git a/native/suidhelper/tests/util/safe_path.proptest-regressions b/native/suidhelper/tests/util/safe_path.proptest-regressions new file mode 100644 index 00000000..2955368a --- /dev/null +++ b/native/suidhelper/tests/util/safe_path.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 4b6f0a1224149926f55e3191ef0b1faff6811e4e775231dc180f46d6f5a2a223 # shrinks to prefix = [], bad = "", suffix = [] diff --git a/native/suidhelper/tests/util/safe_path.rs b/native/suidhelper/tests/util/safe_path.rs index 1c66975d..8efe2efa 100644 --- a/native/suidhelper/tests/util/safe_path.rs +++ b/native/suidhelper/tests/util/safe_path.rs @@ -5,7 +5,7 @@ use hyper_suidhelper::fs::path::{ IsAbsolute, RelativeError, SafePathBuf, StrictComponents, ValidationError, }; use proptest::prelude::*; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; // The fully-enforced flavor used at every real call site. type Strict = SafePathBuf; @@ -55,9 +55,10 @@ proptest! { // component collapses it to a bare separator, hiding the `//` we want to // test. Raw joining makes `.`, `..`, and empty (`//`) literally present. let path = PathBuf::from(format!("/{}", parts.join("/"))); + prop_assume!(path != Path::new("/")); prop_assert!(matches!( Strict::try_from(path), - Err(ValidationError::LooseComponents) + Err(ValidationError::Component(_)) )); } @@ -88,7 +89,7 @@ proptest! { .strip_base(&base) .unwrap() .segments() - .map(|s| s.to_string_lossy().into_owned()) + .map(|s| s.as_os_str().to_string_lossy().into_owned()) .collect(); prop_assert_eq!(&segments, &rel_parts); diff --git a/test/hyper/suid_helper/losetup_test.exs b/test/hyper/suid_helper/losetup_test.exs deleted file mode 100644 index 4cdc6f0f..00000000 --- a/test/hyper/suid_helper/losetup_test.exs +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Hyper.SuidHelper.LosetupTest do - @moduledoc """ - `parse_list/1` turns `losetup --list --output NAME,BACK-FILE` rows into - `{device, backing_file}` pairs. The reclaim pass relies on it to recognise loop - devices backing Hyper's files, so the edges that matter are: a loop with no - backing file (skipped, nothing to reclaim by file) and a `(deleted)` backing - suffix (kept, so the data-dir prefix still matches). - """ - use ExUnit.Case, async: true - - alias Hyper.SuidHelper.Losetup - - test "pairs device with backing file and skips rows that have no backing file" do - out = """ - /dev/loop0 /srv/hyper/scratch/thinpool.meta - /dev/loop1 /srv/hyper/layers/blob - /dev/loop2 - """ - - assert Losetup.parse_list(out) == [ - {"/dev/loop0", "/srv/hyper/scratch/thinpool.meta"}, - {"/dev/loop1", "/srv/hyper/layers/blob"} - ] - end - - test "keeps a `(deleted)` backing suffix so data-dir prefix matching still works" do - out = "/dev/loop0 /srv/hyper/scratch/thinpool.data (deleted)\n" - - assert Losetup.parse_list(out) == [ - {"/dev/loop0", "/srv/hyper/scratch/thinpool.data (deleted)"} - ] - end - - test "an empty listing yields no pairs" do - assert Losetup.parse_list("") == [] - end -end From 1c1276e17653dd1072288de3671d1b0d8f844684 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 22:47:14 -0700 Subject: [PATCH 08/17] some more cleanup --- native/suidhelper/Cargo.toml | 8 ++ native/suidhelper/src/fs/path.rs | 72 +++++++++- native/suidhelper/src/linux/dm/device.rs | 27 ++-- native/suidhelper/src/linux/dm/mod.rs | 16 ++- native/suidhelper/src/linux/dm/name.rs | 61 +++++++++ native/suidhelper/src/linux/dm/table.rs | 30 ++--- native/suidhelper/src/linux/dm/thin.rs | 26 ++-- native/suidhelper/src/linux/loopdev.rs | 59 +++++++- native/suidhelper/src/process/mod.rs | 1 + native/suidhelper/src/process/privilege.rs | 127 ++++++++++++++++++ native/suidhelper/src/tools/dmsetup/mod.rs | 28 ++-- native/suidhelper/src/tools/dmsetup/name.rs | 21 +++ native/suidhelper/src/tools/dmsetup/table.rs | 45 ++++--- native/suidhelper/src/tools/mod.rs | 4 +- native/suidhelper/src/tools/thin_dump.rs | 10 +- native/suidhelper/src/util/safe_dev.rs | 89 +++--------- native/suidhelper/tests/linux/dm.rs | 68 +++++++++- native/suidhelper/tests/linux/loopdev.rs | 56 ++++++++ native/suidhelper/tests/process/privilege.rs | 19 +++ .../suidhelper/tests/tools/dmsetup_parsers.rs | 22 ++- native/suidhelper/tests/util/safe_dev.rs | 100 +++----------- 21 files changed, 642 insertions(+), 247 deletions(-) create mode 100644 native/suidhelper/src/linux/dm/name.rs create mode 100644 native/suidhelper/src/process/privilege.rs create mode 100644 native/suidhelper/src/tools/dmsetup/name.rs create mode 100644 native/suidhelper/tests/linux/loopdev.rs create mode 100644 native/suidhelper/tests/process/privilege.rs diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 91bd9511..dfec488c 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -44,6 +44,10 @@ path = "tests/config_seam.rs" name = "util_safe_bin" path = "tests/util/safe_bin.rs" +[[test]] +name = "process_privilege" +path = "tests/process/privilege.rs" + [[test]] name = "tools_dmsetup_parsers" path = "tests/tools/dmsetup_parsers.rs" @@ -52,6 +56,10 @@ path = "tests/tools/dmsetup_parsers.rs" name = "linux_dm" path = "tests/linux/dm.rs" +[[test]] +name = "linux_loopdev" +path = "tests/linux/loopdev.rs" + [[test]] name = "tools_blockcopy" path = "tests/tools/blockcopy.rs" diff --git a/native/suidhelper/src/fs/path.rs b/native/suidhelper/src/fs/path.rs index 1f2ba930..771b9dd6 100644 --- a/native/suidhelper/src/fs/path.rs +++ b/native/suidhelper/src/fs/path.rs @@ -194,18 +194,36 @@ impl Components for StrictComponents { fn check(path: &Path) -> Result<(), ValidationError> { // This implementation cannot use Path::components because it // normalizes path elements. - let raw = path.as_os_str().as_bytes(); - if raw == b"/" { - return Ok(()); + Ok(check_strict_components(path.as_os_str().as_bytes())?) + } +} + +const fn check_strict_components(raw: &[u8]) -> Result<(), ComponentError> { + let mut rest = match raw { + [b'/'] => return Ok(()), + [b'/', body @ ..] => body, + _ => raw, + }; + loop { + let mut end = 0; + while end < rest.len() && rest[end] != b'/' { + end += 1; } - let body = raw.strip_prefix(b"/").unwrap_or(raw); - for segment in body.split(|&b| b == b'/') { - StrictComponent::check(segment)?; + let (segment, tail) = rest.split_at(end); + if let Err(e) = StrictComponent::check(segment) { + return Err(e); + } + match tail { + [] => return Ok(()), + [_, next @ ..] => rest = next, } - Ok(()) } } +const fn is_strict_absolute(raw: &[u8]) -> bool { + matches!(raw, [b'/', ..]) && check_strict_components(raw).is_ok() +} + /// A [`Path`] which satisfies [`Absoluteness`] and [`Components`] /// constraints. The borrowed counterpart of [`SafePathBuf`]. #[derive(Debug, RefCastCustom, AsRef)] @@ -224,9 +242,30 @@ pub struct SafePathBuf(PathBuf, PhantomData<(A, S)>); impl SafePath { #[ref_cast_custom] fn new_unchecked(path: &Path) -> &Self; + + pub fn as_path(&self) -> &Path { + &self.path + } +} + +impl SafePath { + pub fn from_static() -> &'static Self { + const { + assert!( + is_strict_absolute(PATH.as_bytes()), + "not a strict absolute path" + ) + }; + Self::new_unchecked(Path::new(PATH)) + } } impl SafePath { + /// Join a component to create a new [`SafePathBuf`]. + pub fn join(&self, name: &StrictComponent) -> SafePathBuf { + SafePathBuf(self.path.join(name.as_path()), PhantomData) + } + /// Given `/b/x/y`, base `/b`, returns `x/y`. pub fn strip_base( &self, @@ -290,6 +329,25 @@ impl Deref for SafePathBuf { } } +impl Borrow> for SafePathBuf { + fn borrow(&self) -> &SafePath { + self + } +} + +impl ToOwned for SafePath { + type Owned = SafePathBuf; + fn to_owned(&self) -> SafePathBuf { + SafePathBuf(self.path.to_path_buf(), PhantomData) + } +} + +impl From> for PathBuf { + fn from(path: SafePathBuf) -> Self { + path.0 + } +} + impl TryFrom for SafePathBuf where A: Absoluteness, diff --git a/native/suidhelper/src/linux/dm/device.rs b/native/suidhelper/src/linux/dm/device.rs index 8ff231fe..4227465a 100644 --- a/native/suidhelper/src/linux/dm/device.rs +++ b/native/suidhelper/src/linux/dm/device.rs @@ -1,35 +1,40 @@ -use super::{Dmsetup, RemoveMode, RunError}; -use std::path::PathBuf; +use super::{DmName, Dmsetup, RemoveMode, RunError}; +use crate::fs::path::{IsAbsolute, SafePathBuf, StrictComponents}; +use std::ffi::OsStr; #[derive(Debug, Clone)] pub struct Device<'a> { pub(super) dmsetup: &'a Dmsetup, - pub(super) name: String, + pub(super) name: DmName, } impl<'a> Device<'a> { - pub fn name(&self) -> &str { + pub fn name(&self) -> &DmName { &self.name } - pub fn path(&self) -> PathBuf { - PathBuf::from(format!("/dev/mapper/{}", self.name)) + pub fn path(&self) -> SafePathBuf { + self.name.path() } pub fn suspend(&self) -> Result<(), RunError> { - self.dmsetup.run(["suspend", self.name.as_str()]).map(drop) + self.dmsetup + .run([OsStr::new("suspend"), self.name.as_os_str()]) + .map(drop) } pub fn resume(&self) -> Result<(), RunError> { - self.dmsetup.run(["resume", self.name.as_str()]).map(drop) + self.dmsetup + .run([OsStr::new("resume"), self.name.as_os_str()]) + .map(drop) } pub fn remove(self, mode: RemoveMode) -> Result<(), RunError> { - let mut args = vec!["remove"]; + let mut args = vec![OsStr::new("remove")]; if mode == RemoveMode::Retry { - args.push("--retry"); + args.push(OsStr::new("--retry")); } - args.push(self.name.as_str()); + args.push(self.name.as_os_str()); self.dmsetup.run(args).map(drop) } } diff --git a/native/suidhelper/src/linux/dm/mod.rs b/native/suidhelper/src/linux/dm/mod.rs index 69502a5d..d5d8a773 100644 --- a/native/suidhelper/src/linux/dm/mod.rs +++ b/native/suidhelper/src/linux/dm/mod.rs @@ -1,6 +1,7 @@ //! Utilities for operating on linux `dsetup`. mod device; +mod name; mod table; mod thin; pub mod thin_dump; @@ -8,6 +9,7 @@ pub mod thin_dump; use super::Access; use crate::process::binary::RootSafeBin; pub use device::Device; +pub use name::{DmName, InvalidDmName}; use std::ffi::OsStr; use std::io; pub use table::{DmTable, LinearTable, Persistence, SnapshotTable, ThinPoolTable, ThinTable}; @@ -57,28 +59,28 @@ impl Dmsetup { pub fn create( &self, - name: &str, + name: &DmName, table: &DmTable, access: Access, ) -> Result, RunError> { let table = table.to_string(); - let mut args = vec!["create", name]; + let mut args = vec![OsStr::new("create"), name.as_os_str()]; if access == Access::ReadOnly { - args.push("--readonly"); + args.push(OsStr::new("--readonly")); } - args.extend(["--table", table.as_str()]); + args.extend([OsStr::new("--table"), OsStr::new(&table)]); self.run(args)?; Ok(self.device(name)) } - pub fn device(&self, name: &str) -> Device<'_> { + pub fn device(&self, name: &DmName) -> Device<'_> { Device { dmsetup: self, - name: name.to_string(), + name: name.clone(), } } - pub fn thin_pool(&self, name: &str) -> Result, ThinPoolError> { + pub fn thin_pool(&self, name: &DmName) -> Result, ThinPoolError> { ThinPool::open(self, name) } diff --git a/native/suidhelper/src/linux/dm/name.rs b/native/suidhelper/src/linux/dm/name.rs new file mode 100644 index 00000000..01ece850 --- /dev/null +++ b/native/suidhelper/src/linux/dm/name.rs @@ -0,0 +1,61 @@ +use crate::fs::path::{ + IsAbsolute, SafePath, SafePathBuf, StrictComponent, StrictComponentBuf, StrictComponents, +}; +use std::ffi::OsStr; +use std::fmt; +use std::str::FromStr; +use thiserror::Error as ThisError; + +const DM_NAME_MAX: usize = 127; + +#[derive(Debug, ThisError)] +#[error("invalid dm device name: {0:?}")] +pub struct InvalidDmName(String); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DmName(StrictComponentBuf); + +fn is_dm_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= DM_NAME_MAX + && !name.starts_with('-') + && !matches!(name, "." | ".." | "control") + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"#+-.:=@_".contains(&b)) +} + +impl DmName { + pub fn as_os_str(&self) -> &OsStr { + self.0.as_os_str() + } + + pub fn path(&self) -> SafePathBuf { + SafePath::from_static::<"/dev/mapper">().join(&self.0) + } +} + +impl FromStr for DmName { + type Err = InvalidDmName; + + fn from_str(s: &str) -> Result { + let invalid = || InvalidDmName(s.to_string()); + if !is_dm_name(s) { + return Err(invalid()); + } + let component: &StrictComponent = s.try_into().map_err(|_| invalid())?; + Ok(Self(component.to_owned())) + } +} + +impl AsRef for DmName { + fn as_ref(&self) -> &OsStr { + self.as_os_str() + } +} + +impl fmt::Display for DmName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.as_os_str().to_string_lossy()) + } +} diff --git a/native/suidhelper/src/linux/dm/table.rs b/native/suidhelper/src/linux/dm/table.rs index 4169ec87..45de61b0 100644 --- a/native/suidhelper/src/linux/dm/table.rs +++ b/native/suidhelper/src/linux/dm/table.rs @@ -1,6 +1,6 @@ -use super::ThinId; +use super::{DmName, ThinId}; +use crate::fs::path::{IsAbsolute, SafePathBuf, StrictComponents}; use derive_more::Display; -use std::path::PathBuf; /// Dmsetup tables. See `man dmsetup` for more info, under the `--table` docs. #[derive(Debug, Clone, Display)] @@ -20,19 +20,19 @@ pub enum Persistence { } #[derive(Debug, Clone, Display)] -#[display("0 {sectors} linear {} 0", origin.display())] +#[display("0 {sectors} linear {} 0", origin.as_path().display())] pub struct LinearTable { pub sectors: u64, - pub origin: PathBuf, + pub origin: SafePathBuf, } /// `dm-snapshot` table. #[derive(Debug, Clone, Display)] -#[display("0 {sectors} snapshot {} {} {persistence} {chunk}", origin.display(), cow.display())] +#[display("0 {sectors} snapshot {} {} {persistence} {chunk}", origin.as_path().display(), cow.as_path().display())] pub struct SnapshotTable { pub sectors: u64, - pub origin: PathBuf, - pub cow: PathBuf, + pub origin: SafePathBuf, + pub cow: SafePathBuf, pub persistence: Persistence, pub chunk: u64, } @@ -41,13 +41,13 @@ pub struct SnapshotTable { #[derive(Debug, Clone, Display)] #[display( "0 {sectors} thin-pool {} {} {block_sectors} {low_water}", - metadata.display(), - data.display() + metadata.as_path().display(), + data.as_path().display() )] pub struct ThinPoolTable { pub sectors: u64, - pub metadata: PathBuf, - pub data: PathBuf, + pub metadata: SafePathBuf, + pub data: SafePathBuf, pub block_sectors: u64, pub low_water: u64, } @@ -56,12 +56,12 @@ pub struct ThinPoolTable { #[derive(Debug, Clone, Display)] #[display( "0 {sectors} thin {} {id}{}", - pool.display(), - origin.as_ref().map(|o| format!(" {}", o.display())).unwrap_or_default() + pool.path().as_path().display(), + origin.as_ref().map(|o| format!(" {}", o.as_path().display())).unwrap_or_default() )] pub struct ThinTable { pub sectors: u64, - pub pool: PathBuf, + pub pool: DmName, pub id: ThinId, - pub origin: Option, + pub origin: Option>, } diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs index 81682a1b..99a77120 100644 --- a/native/suidhelper/src/linux/dm/thin.rs +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -1,7 +1,8 @@ -use super::{Device, DmTable, Dmsetup, RemoveMode, RunError, ThinTable}; +use super::{Device, DmName, DmTable, Dmsetup, RemoveMode, RunError, ThinTable}; +use crate::fs::path::{IsAbsolute, SafePathBuf, StrictComponents}; use crate::linux::Access; +use std::ffi::OsStr; use std::fmt; -use std::path::PathBuf; use thiserror::Error as ThisError; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -40,7 +41,7 @@ pub enum ThinPoolError { #[error(transparent)] Run(#[from] RunError), #[error("{name} is not a thin-pool device: {table:?}")] - NotAThinPool { name: String, table: String }, + NotAThinPool { name: DmName, table: String }, } #[derive(Debug, ThisError)] @@ -72,14 +73,14 @@ pub struct Thin<'a, S> { } impl<'a> ThinPool<'a> { - pub(super) fn open(dmsetup: &'a Dmsetup, name: &str) -> Result { - let table = dmsetup.run(["table", name])?; + pub(super) fn open(dmsetup: &'a Dmsetup, name: &DmName) -> Result { + let table = dmsetup.run([OsStr::new("table"), name.as_os_str()])?; match table.lines().collect::>().as_slice() { [line] if line.split_whitespace().nth(2) == Some("thin-pool") => Ok(Self { device: dmsetup.device(name), }), _ => Err(ThinPoolError::NotAThinPool { - name: name.to_string(), + name: name.clone(), table, }), } @@ -117,7 +118,12 @@ impl<'a> ThinPool<'a> { fn message(&self, message: &str) -> Result<(), RunError> { self.device .dmsetup - .run(["message", self.device.name(), "0", message]) + .run([ + OsStr::new("message"), + self.device.name().as_os_str(), + OsStr::new("0"), + OsStr::new(message), + ]) .map(drop) } } @@ -131,13 +137,13 @@ impl Thin<'_, S> { impl<'a> Thin<'a, Inactive> { pub fn activate( self, - name: &str, + name: &DmName, sectors: u64, - origin: Option, + origin: Option>, ) -> Result>, RunError> { let table = DmTable::Thin(ThinTable { sectors, - pool: self.pool.device.path(), + pool: self.pool.device.name().clone(), id: self.id, origin, }); diff --git a/native/suidhelper/src/linux/loopdev.rs b/native/suidhelper/src/linux/loopdev.rs index 63937ff1..ec5224d2 100644 --- a/native/suidhelper/src/linux/loopdev.rs +++ b/native/suidhelper/src/linux/loopdev.rs @@ -4,12 +4,15 @@ use crate::fs::dev::DeviceNumber; use crate::fs::file::{self, Any, IsBlockDevice, IsCharDevice, IsRegularFile, OpenFlags, SafeFile}; use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; use nix::errno::Errno; +use std::fmt; use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::time::{Duration, Instant}; use thiserror::Error as ThisError; const CONTROL_PATH: &str = "/dev/loop-control"; +const DEVICE_PREFIX: &str = "/dev/loop"; const LOOP_CONTROL: DeviceNumber = DeviceNumber::new(10, 237); const LOOP_MAJOR: u64 = 7; const ATTACH_BACKOFF: Duration = Duration::from_millis(5); @@ -73,6 +76,51 @@ impl Default for LoopInfo64 { } } +#[derive(Debug, ThisError)] +#[error("expected /dev/loopN: {0:?}")] +pub struct InvalidLoopDevNum(String); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoopDevNum(u32); + +impl LoopDevNum { + pub fn get(self) -> u32 { + self.0 + } + + pub fn path(self) -> Result, path::ValidationError> { + PathBuf::from(self.to_string()).try_into() + } +} + +impl From for LoopDevNum { + fn from(number: u32) -> Self { + Self(number) + } +} + +impl FromStr for LoopDevNum { + type Err = InvalidLoopDevNum; + + fn from_str(s: &str) -> Result { + let invalid = || InvalidLoopDevNum(s.to_string()); + let digits = s.strip_prefix(DEVICE_PREFIX).ok_or_else(invalid)?; + let canonical = digits == "0" + || (digits.starts_with(|c: char| c.is_ascii_digit() && c != '0') + && digits.bytes().all(|b| b.is_ascii_digit())); + if !canonical { + return Err(invalid()); + } + digits.parse().map(Self).map_err(|_| invalid()) + } +} + +impl fmt::Display for LoopDevNum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{DEVICE_PREFIX}{}", self.0) + } +} + #[derive(Debug, ThisError)] pub enum OpenError { #[error("loop device path: {0}")] @@ -147,7 +195,7 @@ impl LoopControl { let raw = unsafe { ioctl::ctl_get_free(self.0.as_raw_fd()) }.map_err(AttachError::GetFree)?; let number = u32::try_from(raw).map_err(|_| AttachError::Number(raw))?; - let device = LoopDevice::open(number).map_err(AttachError::Open)?; + let device = LoopDevice::open(number.into()).map_err(AttachError::Open)?; // SAFETY: `config` is a fully initialised `struct loop_config`. match unsafe { ioctl::configure(device.file.as_raw_fd(), &config) } { Ok(_) => return Ok(device), @@ -164,15 +212,14 @@ impl LoopControl { } pub struct LoopDevice { - number: u32, + number: LoopDevNum, path: SafePathBuf, file: SafeFile, } impl LoopDevice { - pub fn open(number: u32) -> Result { - let path: SafePathBuf = - PathBuf::from(format!("/dev/loop{number}")).try_into()?; + pub fn open(number: LoopDevNum) -> Result { + let path = number.path()?; let file: SafeFile = SafeFile::open(&path, OpenFlags::RDWR)?; let device = file.rdev()?; @@ -186,7 +233,7 @@ impl LoopDevice { Ok(Self { number, path, file }) } - pub fn number(&self) -> u32 { + pub fn number(&self) -> LoopDevNum { self.number } diff --git a/native/suidhelper/src/process/mod.rs b/native/suidhelper/src/process/mod.rs index 96eab668..64b092b0 100644 --- a/native/suidhelper/src/process/mod.rs +++ b/native/suidhelper/src/process/mod.rs @@ -1 +1,2 @@ pub mod binary; +pub mod privilege; diff --git a/native/suidhelper/src/process/privilege.rs b/native/suidhelper/src/process/privilege.rs new file mode 100644 index 00000000..174ecdf9 --- /dev/null +++ b/native/suidhelper/src/process/privilege.rs @@ -0,0 +1,127 @@ +//! Setuid privilege transitions. +//! +//! A program using this utility runs with privileges lowered to that of the caller. Root is only +//! reachable through the [`Unprivileged`] token. +//! +//! [`Unprivileged::elevate`] returns a [`Root`] guard. While it's in scope, the the process is +//! euid/egid 0. Dropping lowers it back. +//! +//! The helper runs with its effective ids lowered to the caller's. Root is only +//! reachable through the single [`Unprivileged`] token, taken once in `main`: + +use derive_more::Display; +use nix::errno::Errno; +use nix::libc::{self, c_uint}; +use nix::unistd::{getgid, getuid, setegid, seteuid, setgroups, setresgid, setresuid, Gid, Uid}; +use std::io; +use std::marker::PhantomData; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use thiserror::Error as ThisError; + +static TAKEN: AtomicBool = AtomicBool::new(false); + +const ROOT_UID: Uid = Uid::from_raw(0); +const ROOT_GID: Gid = Gid::from_raw(0); +const FIRST_INHERITED_FD: c_uint = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)] +pub enum SealStep { + #[display("euid")] + Euid, + #[display("gids")] + Gids, + #[display("supplementary groups")] + Groups, + #[display("uids")] + Uids, +} + +#[derive(Debug, ThisError)] +pub enum Error { + #[error("raising euid to root: {0}")] + RaiseUser(#[source] Errno), + #[error("raising egid to root: {0}")] + RaiseGroup(#[source] Errno), + #[error("sealing permanent root ({step}): {errno}")] + Seal { + step: SealStep, + #[source] + errno: Errno, + }, + #[error("marking inherited fds close-on-exec: {0}")] + MarkCloexec(#[source] Errno), +} + +#[derive(Debug)] +pub struct Unprivileged { + _seal: (), +} + +#[must_use = "privileges are lowered when the guard is dropped; bind it for the privileged scope"] +#[derive(Debug)] +pub struct Root<'a> { + _token: PhantomData<&'a mut Unprivileged>, +} + +impl Unprivileged { + pub fn take() -> Option { + (!TAKEN.swap(true, Ordering::Relaxed)).then_some(Self { _seal: () }) + } + + pub fn elevate(&mut self) -> Result, Error> { + seteuid(ROOT_UID).map_err(Error::RaiseUser)?; + let root = Root { + _token: PhantomData, + }; + setegid(ROOT_GID).map_err(Error::RaiseGroup)?; + Ok(root) + } +} + +pub trait RootCommandExt { + fn exec_as_root(&mut self, token: Unprivileged) -> io::Error; +} + +impl RootCommandExt for Command { + fn exec_as_root(&mut self, token: Unprivileged) -> io::Error { + let Unprivileged { _seal: () } = token; + let hook = || { + // SAFETY: close_range takes no memory operands. + let rc = unsafe { + libc::syscall( + libc::SYS_close_range, + FIRST_INHERITED_FD, + c_uint::MAX, + libc::CLOSE_RANGE_CLOEXEC, + ) + }; + if rc != 0 { + return Err(io::Error::other(Error::MarkCloexec(Errno::last()))); + } + + let seal = |step| move |errno| io::Error::other(Error::Seal { step, errno }); + seteuid(ROOT_UID).map_err(seal(SealStep::Euid))?; + setresgid(ROOT_GID, ROOT_GID, ROOT_GID).map_err(seal(SealStep::Gids))?; + setgroups(&[ROOT_GID]).map_err(seal(SealStep::Groups))?; + setresuid(ROOT_UID, ROOT_UID, ROOT_UID).map_err(seal(SealStep::Uids)) + }; + + self.env_clear(); + // SAFETY: `exec` runs this hook in the current process (there is no + // fork), right before `execve`. + unsafe { self.pre_exec(hook) }; + self.exec() + } +} + +impl Drop for Root<'_> { + fn drop(&mut self) { + let lowered = setegid(getgid()).and_then(|()| seteuid(getuid())); + if let Err(errno) = lowered { + eprintln!("hyper-suidhelper: failed to drop privileges ({errno}); aborting"); + std::process::abort(); + } + } +} diff --git a/native/suidhelper/src/tools/dmsetup/mod.rs b/native/suidhelper/src/tools/dmsetup/mod.rs index 8b490781..f1021b13 100644 --- a/native/suidhelper/src/tools/dmsetup/mod.rs +++ b/native/suidhelper/src/tools/dmsetup/mod.rs @@ -1,16 +1,17 @@ mod backing; mod message; +mod name; mod table; use super::IsTool; -use crate::linux::dm::{self, RemoveMode}; +use crate::linux::dm::{self, DmName, RemoveMode}; use crate::linux::loopdev::LoopControl; use crate::linux::Access; use crate::process::binary::RootSafeBin; -use crate::util::safe_dev::DmName; pub use backing::{BackingError, BackingFile}; use clap::{Args, Subcommand}; pub use message::{parse_message, MessageError, ThinMessage}; +pub use name::{parse_dm_name, NameError}; use serde::Serialize; use std::collections::BTreeMap; use std::path::PathBuf; @@ -28,6 +29,7 @@ pub struct DmsetupArgs { #[derive(Subcommand)] enum DmOp { Create { + #[arg(value_parser = parse_dm_name)] name: DmName, #[arg(long)] readonly: bool, @@ -37,18 +39,26 @@ enum DmOp { Remove { #[arg(long)] retry: bool, + #[arg(value_parser = parse_dm_name)] name: DmName, }, Message { + #[arg(value_parser = parse_dm_name)] name: DmName, #[arg(long, value_parser = parse_message)] message: ThinMessage, }, /// Suspend a dm device (flushes queued I/O; required before `create_snap` /// of the device's thin id). - Suspend { name: DmName }, + Suspend { + #[arg(value_parser = parse_dm_name)] + name: DmName, + }, /// Resume a suspended dm device. - Resume { name: DmName }, + Resume { + #[arg(value_parser = parse_dm_name)] + name: DmName, + }, /// List the target types the kernel device-mapper exposes. Read-only, but /// still needs root: it opens `/dev/mapper/control`. Targets, @@ -120,7 +130,7 @@ impl IsTool for Dmsetup { Ok::<_, Box>(device) })?; DmsetupOut::Created { - device: dm.create(name.as_ref(), &attached.to_dm(), access)?.path(), + device: dm.create(name, &attached.to_dm(), access)?.path().into(), loops, } } @@ -130,11 +140,11 @@ impl IsTool for Dmsetup { } else { RemoveMode::Once }; - dm.device(name.as_ref()).remove(mode)?; + dm.device(name).remove(mode)?; DmsetupOut::Removed } DmOp::Message { name, message } => { - let pool = dm.thin_pool(name.as_ref())?; + let pool = dm.thin_pool(name)?; match *message { ThinMessage::CreateThin(id) => pool.create_thin(id).map(drop)?, ThinMessage::CreateSnap { id, origin } => pool.create_snap(id, origin)?, @@ -145,11 +155,11 @@ impl IsTool for Dmsetup { DmsetupOut::Messaged } DmOp::Suspend { name } => { - dm.device(name.as_ref()).suspend()?; + dm.device(name).suspend()?; DmsetupOut::Suspended } DmOp::Resume { name } => { - dm.device(name.as_ref()).resume()?; + dm.device(name).resume()?; DmsetupOut::Resumed } DmOp::Targets => DmsetupOut::Targets { diff --git a/native/suidhelper/src/tools/dmsetup/name.rs b/native/suidhelper/src/tools/dmsetup/name.rs new file mode 100644 index 00000000..a7a3e752 --- /dev/null +++ b/native/suidhelper/src/tools/dmsetup/name.rs @@ -0,0 +1,21 @@ +use crate::linux::dm::{DmName, InvalidDmName}; +use thiserror::Error as ThisError; + +const HYPER_PREFIX: &str = "hyper-"; + +#[derive(Debug, ThisError)] +pub enum NameError { + #[error(transparent)] + Invalid(#[from] InvalidDmName), + #[error("dm device name must start with `{HYPER_PREFIX}`: {0}")] + NotHyper(DmName), +} + +pub fn parse_dm_name(s: &str) -> Result { + let name: DmName = s.parse()?; + if s.starts_with(HYPER_PREFIX) { + Ok(name) + } else { + Err(NameError::NotHyper(name)) + } +} diff --git a/native/suidhelper/src/tools/dmsetup/table.rs b/native/suidhelper/src/tools/dmsetup/table.rs index 24045aa6..fb50da73 100644 --- a/native/suidhelper/src/tools/dmsetup/table.rs +++ b/native/suidhelper/src/tools/dmsetup/table.rs @@ -1,15 +1,16 @@ use super::backing::{BackingError, BackingFile}; +use super::name::{parse_dm_name, NameError}; use crate::config::Config; -use crate::fs::path::{self, IsAbsolute, SafePathBuf, StrictComponents}; +use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; use crate::linux::dm::{ - DmTable, LinearTable, Persistence, SnapshotTable, ThinId, ThinIdOutOfRange, ThinPoolTable, - ThinTable, + DmName, DmTable, LinearTable, Persistence, SnapshotTable, ThinId, ThinIdOutOfRange, + ThinPoolTable, ThinTable, }; use crate::linux::loopdev::LoopDevice; use crate::util::safe_dev::{self, BlockDev}; use serde::Serialize; use std::fmt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use thiserror::Error as ThisError; pub type FilePath = SafePathBuf; @@ -20,6 +21,8 @@ pub enum TableError { Malformed(String), #[error(transparent)] Device(#[from] safe_dev::Error), + #[error(transparent)] + Name(#[from] NameError), #[error("expected a backing file, got a device: {0}")] NotAFile(String), #[error("backing file path {0:?}: {1}")] @@ -67,7 +70,7 @@ pub enum TableSpec { }, Thin { sectors: u64, - pool: BlockDev, + pool: DmName, id: ThinId, origin: Option>, }, @@ -141,26 +144,26 @@ impl TableSpec { } pub trait SlotPath { - fn slot_path(&self) -> &Path; + fn slot_path(&self) -> &SafePath; } impl SlotPath for FilePath { - fn slot_path(&self) -> &Path { - (**self).as_ref() + fn slot_path(&self) -> &SafePath { + self } } impl SlotPath for LoopDevice { - fn slot_path(&self) -> &Path { - self.path().as_ref() + fn slot_path(&self) -> &SafePath { + self.path() } } impl Source { - fn path(&self) -> PathBuf { + fn path(&self) -> FilePath { match self { - Source::Device(dev) => AsRef::::as_ref(dev).to_path_buf(), - Source::File(file) => file.slot_path().to_path_buf(), + Source::Device(dev) => dev.path().to_owned(), + Source::File(file) => file.slot_path().to_owned(), } } } @@ -181,7 +184,7 @@ impl TableSpec { } => DmTable::Snapshot(SnapshotTable { sectors: *sectors, origin: origin.path(), - cow: cow.slot_path().to_path_buf(), + cow: cow.slot_path().to_owned(), persistence: *persistence, chunk: *chunk, }), @@ -193,8 +196,8 @@ impl TableSpec { low_water, } => DmTable::ThinPool(ThinPoolTable { sectors: *sectors, - metadata: metadata.slot_path().to_path_buf(), - data: data.slot_path().to_path_buf(), + metadata: metadata.slot_path().to_owned(), + data: data.slot_path().to_owned(), block_sectors: *block_sectors, low_water: *low_water, }), @@ -205,7 +208,7 @@ impl TableSpec { origin, } => DmTable::Thin(ThinTable { sectors: *sectors, - pool: AsRef::::as_ref(pool).to_path_buf(), + pool: pool.clone(), id: *id, origin: origin.as_ref().map(Source::path), }), @@ -234,6 +237,10 @@ pub fn parse_table(s: &str) -> Result, TableError> { .try_into() .map_err(|e| TableError::FilePath(t.to_string(), e)) }; + let dm_device = |t: &str| -> Result { + let name = t.strip_prefix("/dev/mapper/").ok_or_else(malformed)?; + Ok(parse_dm_name(name)?) + }; let source = |t: &str| -> Result, TableError> { if is_device(t) { Ok(Source::Device(t.parse()?)) @@ -271,7 +278,7 @@ pub fn parse_table(s: &str) -> Result, TableError> { }, ["0", sectors, "thin", pool, id, origin @ ..] if origin.len() <= 1 => TableSpec::Thin { sectors: number(sectors)?, - pool: pool.parse()?, + pool: dm_device(pool)?, id: thin_id(id)?, origin: origin.first().map(|o| source(o)).transpose()?, }, @@ -281,5 +288,5 @@ pub fn parse_table(s: &str) -> Result, TableError> { pub fn resolve_table(s: &str) -> Result, TableError> { let base = Config::get().hyper_base(); - Ok(parse_table(s)?.try_map(|path, _| BackingFile::open(path.slot_path(), base))?) + Ok(parse_table(s)?.try_map(|path, _| BackingFile::open(path.slot_path().as_path(), base))?) } diff --git a/native/suidhelper/src/tools/mod.rs b/native/suidhelper/src/tools/mod.rs index f1069997..0d3def24 100644 --- a/native/suidhelper/src/tools/mod.rs +++ b/native/suidhelper/src/tools/mod.rs @@ -16,8 +16,8 @@ pub use blockcopy::{Blockcopy, BlockcopyArgs}; pub use blockdev::{Blockdev, BlockdevArgs}; pub use chroot_jail::ChrootJailOp; pub use dmsetup::{ - parse_message, parse_table, BackingError, BackingFile, Dmsetup, DmsetupArgs, MessageError, - Slot, Source, TableError, TableSpec, ThinMessage, + parse_dm_name, parse_message, parse_table, BackingError, BackingFile, Dmsetup, DmsetupArgs, + MessageError, NameError, Slot, Source, TableError, TableSpec, ThinMessage, }; pub use network::NetworkOp; pub use thin_dump::{ThinDump, ThinDumpArgs}; diff --git a/native/suidhelper/src/tools/thin_dump.rs b/native/suidhelper/src/tools/thin_dump.rs index bf2404b9..d0cf2a06 100644 --- a/native/suidhelper/src/tools/thin_dump.rs +++ b/native/suidhelper/src/tools/thin_dump.rs @@ -13,11 +13,11 @@ use thiserror::Error as ThisError; use super::IsTool; use crate::fs::file::{self, Any, IsBlockDevice, OpenFlags, SafeFile}; -use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::fs::path; use crate::linux::dm::thin_dump::{self as dump, DumpError, MetadataDump, ThinMappings}; use crate::linux::dm::ThinId; +use crate::linux::loopdev::LoopDevNum; use crate::process::binary::RootSafeBin; -use crate::util::safe_dev::LoopDev; use clap::Args; #[derive(Debug, ThisError)] @@ -34,7 +34,7 @@ pub enum ToolError { pub struct ThinDumpArgs { /// The pool's metadata loop device. #[arg(long)] - meta: LoopDev, + meta: LoopDevNum, /// The thin device id whose provisioned ranges to extract. #[arg(long, value_parser = parse_thin_id)] dev_id: ThinId, @@ -67,8 +67,8 @@ impl IsTool for ThinDump { type RunT = Result; fn run_privileged(&self) -> Self::RunT { - let meta: &SafePath = self.args.meta.as_ref().try_into()?; - let device = SafeFile::::open(meta, OpenFlags::RDONLY)?; + let meta = self.args.meta.path()?; + let device = SafeFile::::open(&meta, OpenFlags::RDONLY)?; // --metadata-snap reads the caller-reserved metadata snapshot, the // only safe way to dump a live pool's metadata device. Ok(self.thin_dump.dump(&device, self.args.dev_id)?) diff --git a/native/suidhelper/src/util/safe_dev.rs b/native/suidhelper/src/util/safe_dev.rs index 80a9fcd5..e8f7b431 100644 --- a/native/suidhelper/src/util/safe_dev.rs +++ b/native/suidhelper/src/util/safe_dev.rs @@ -11,99 +11,50 @@ //! Filesystem path safety (confinement, symlink-free walks, fd-relative ops) //! lives in `crate::fs::{path, file, dir}`, not here. -use derive_more::{AsRef, Display, Into}; +use crate::fs::path::{IsAbsolute, SafePath, SafePathBuf, StrictComponents}; +use crate::linux::loopdev::LoopDevNum; +use crate::tools::parse_dm_name; +use derive_more::{Display, Into}; use std::path::{Path, PathBuf}; use std::str::FromStr; use thiserror::Error as ThisError; #[derive(Debug, ThisError)] pub enum Error { - #[error("expected /dev/loopN: {0}")] - Loop(String), #[error("expected a loop or hyper-* dm device: {0}")] Block(String), - #[error("dm device name must be a safe hyper-* name: {0}")] - Name(String), } -// `/dev/loop` followed by its number and nothing else. Matching the digit suffix -// (not just the prefix) rejects path tricks like `/dev/loop0/../sda`. -fn is_loop(p: &str) -> bool { - p.strip_prefix("/dev/loop") - .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) -} - -// `/dev/mapper/` plus a `hyper-*` name with no path separators, so the device -// is always a direct child of `/dev/mapper` (the charset excludes `/`, so no -// traversal). fn is_hyper_dm(p: &str) -> bool { - p.strip_prefix("/dev/mapper/").is_some_and(is_hyper_name) + p.strip_prefix("/dev/mapper/") + .is_some_and(|name| parse_dm_name(name).is_ok()) } -// A device-mapper name we own: `hyper-*`, restricted to a charset with no path -// separators. Shared by `is_hyper_dm` and [`DmName`] so the two never drift. -fn is_hyper_name(name: &str) -> bool { - name.starts_with("hyper-") - && name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) -} - -/// A loop device path, `/dev/loopN`. -#[derive(Debug, Clone, AsRef, Display)] -#[as_ref(Path)] -#[display("{}", _0.display())] -pub struct LoopDev(PathBuf); - -impl FromStr for LoopDev { - type Err = Error; +#[derive(Debug, Clone, Display, Into)] +#[display("{}", _0.as_path().display())] +#[into(PathBuf)] +pub struct BlockDev(SafePathBuf); - fn from_str(s: &str) -> Result { - if is_loop(s) { - Ok(Self(PathBuf::from(s))) - } else { - Err(Error::Loop(s.to_string())) - } +impl BlockDev { + pub fn path(&self) -> &SafePath { + &self.0 } } -/// A block-device operand: a loop device or one of our own dm devices — never -/// arbitrary system storage. -#[derive(Debug, Clone, AsRef, Display, Into)] -#[as_ref(Path)] -#[display("{}", _0.display())] -pub struct BlockDev(PathBuf); - impl FromStr for BlockDev { type Err = Error; fn from_str(s: &str) -> Result { - if is_loop(s) || is_hyper_dm(s) { - Ok(Self(PathBuf::from(s))) - } else { - Err(Error::Block(s.to_string())) + let invalid = || Error::Block(s.to_string()); + if s.parse::().is_err() && !is_hyper_dm(s) { + return Err(invalid()); } + PathBuf::from(s).try_into().map(Self).map_err(|_| invalid()) } } -/// A device-mapper device name we create/remove: a `hyper-*` name we own. -#[derive(Debug, Clone, Display)] -pub struct DmName(String); - -impl FromStr for DmName { - type Err = Error; - - fn from_str(s: &str) -> Result { - if is_hyper_name(s) { - Ok(Self(s.to_string())) - } else { - Err(Error::Name(s.to_string())) - } - } -} - -impl AsRef for DmName { - fn as_ref(&self) -> &str { - &self.0 +impl AsRef for BlockDev { + fn as_ref(&self) -> &Path { + self.0.as_path() } } diff --git a/native/suidhelper/tests/linux/dm.rs b/native/suidhelper/tests/linux/dm.rs index a9d53ff4..a1fcabd3 100644 --- a/native/suidhelper/tests/linux/dm.rs +++ b/native/suidhelper/tests/linux/dm.rs @@ -1,13 +1,58 @@ use hyper_suidhelper::linux::dm::ThinId; -use hyper_suidhelper::linux::dm::{Dmsetup, Inactive, SnapshotError, Thin, ThinPoolError}; +use hyper_suidhelper::linux::dm::{DmName, Dmsetup, Inactive, SnapshotError, Thin, ThinPoolError}; use hyper_suidhelper::process::binary::RootSafeBin; use proptest::prelude::*; use std::fs; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tempfile::TempDir; +const DM_NAME_CHARS: &str = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#+-.:=@_"; + +fn dm_name() -> impl Strategy { + "[0-9A-Za-z#+.:=@_][0-9A-Za-z#+.:=@_-]{0,126}".prop_filter("reserved", |s| { + !matches!(s.as_str(), "." | ".." | "control") + }) +} + +fn name(s: &str) -> DmName { + s.parse().unwrap() +} + proptest! { + #[test] + fn dm_name_round_trips_and_lives_under_dev_mapper(s in dm_name()) { + let dm: DmName = s.parse().unwrap(); + prop_assert_eq!(dm.to_string(), s.clone()); + let expected = format!("/dev/mapper/{s}"); + let path = dm.path(); + prop_assert_eq!(path.as_path(), Path::new(&expected)); + } + + #[test] + fn dm_name_rejects_any_byte_outside_the_charset( + s in dm_name(), + at in any::(), + bad in any::().prop_filter("charset", |c| !DM_NAME_CHARS.contains(*c)), + ) { + let mut chars: Vec = s.chars().collect(); + chars.insert(at.index(chars.len() + 1), bad); + let s: String = chars.into_iter().collect(); + prop_assert!(s.parse::().is_err(), "accepted {:?}", s); + } + + #[test] + fn dm_name_rejects_names_past_127_bytes(s in "[0-9A-Za-z]{128,200}") { + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn dm_name_rejects_a_leading_dash(s in dm_name()) { + let dashed = format!("-{s}"); + prop_assert!(dashed.parse::().is_err()); + } + #[test] fn thin_id_accepts_exactly_24_bit_values( n in prop_oneof![0..=ThinId::MAX, (ThinId::MAX + 1)..=u32::MAX] @@ -22,6 +67,15 @@ proptest! { } } +#[test] +fn dm_name_rejects_reserved_and_edge_names() { + for bad in [ + "", ".", "..", "control", "-", "--help", "a/b", "/x", "a\0b", "a b", + ] { + assert!(bad.parse::().is_err(), "accepted {bad:?}"); + } +} + const POOL_TABLE: &str = "0 100 thin-pool 7:0 7:1 128 0 0"; fn is_root() -> bool { @@ -80,7 +134,7 @@ fn thin_pool_refuses_a_device_that_is_not_a_pool_as_root() { "", ] { let fake = Fake::new(table, ""); - let got = fake.dmsetup.thin_pool("hyper-x"); + let got = fake.dmsetup.thin_pool(&name("hyper-x")); assert!( matches!(got, Err(ThinPoolError::NotAThinPool { .. })), "{table:?}: {got:?}" @@ -103,11 +157,11 @@ fn snapshot_always_resumes_the_origin_as_root() { |r| matches!(r, Err(SnapshotError::CreateSnap(_))); for (failing_message, expected) in [("", ok), ("create_snap 2 1", create_snap_failed)] { let fake = Fake::new(POOL_TABLE, failing_message); - let pool = fake.dmsetup.thin_pool("hyper-pool").unwrap(); + let pool = fake.dmsetup.thin_pool(&name("hyper-pool")).unwrap(); let active = pool .create_thin(ThinId::new(1).unwrap()) .unwrap() - .activate("hyper-vol", 100, None) + .activate(&name("hyper-vol"), 100, None) .unwrap(); let got = active.snapshot(ThinId::new(2).unwrap()); @@ -135,10 +189,10 @@ fn deactivate_then_delete_removes_the_device_then_frees_its_id_as_root() { return; } let fake = Fake::new(POOL_TABLE, ""); - let pool = fake.dmsetup.thin_pool("hyper-pool").unwrap(); + let pool = fake.dmsetup.thin_pool(&name("hyper-pool")).unwrap(); pool.create_thin(ThinId::new(1).unwrap()) .unwrap() - .activate("hyper-vol", 100, None) + .activate(&name("hyper-vol"), 100, None) .unwrap() .deactivate() .unwrap() diff --git a/native/suidhelper/tests/linux/loopdev.rs b/native/suidhelper/tests/linux/loopdev.rs new file mode 100644 index 00000000..ec9a2b64 --- /dev/null +++ b/native/suidhelper/tests/linux/loopdev.rs @@ -0,0 +1,56 @@ +use hyper_suidhelper::linux::loopdev::LoopDevNum; +use proptest::prelude::*; +use std::path::Path; + +proptest! { + #[test] + fn canonical_names_round_trip(n in any::()) { + let s = format!("/dev/loop{n}"); + let num: LoopDevNum = s.parse().unwrap(); + prop_assert_eq!(num.get(), n); + prop_assert_eq!(num.to_string(), s.clone()); + let path = num.path().unwrap(); + prop_assert_eq!(path.as_path(), Path::new(&s)); + } + + #[test] + fn rejects_leading_zeros(digits in "0[0-9]{1,9}") { + let s = format!("/dev/loop{digits}"); + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_values_past_u32(n in (u64::from(u32::MAX) + 1)..=u64::MAX) { + let s = format!("/dev/loop{n}"); + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_non_numeric_suffix(suffix in "([^0-9].*)?") { + let s = format!("/dev/loop{suffix}"); + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_digit_then_junk(n in any::(), junk in "[^0-9].*") { + let s = format!("/dev/loop{n}{junk}"); + prop_assert!(s.parse::().is_err()); + } +} + +#[test] +fn rejects_known_attack_strings() { + for bad in [ + "/dev/loop", + "/dev/loop+7", + "/dev/loop-1", + "/dev/loop0/../sda", + "/dev/sda", + "../dev/loop0", + "dev/loop0", + "/dev//loop0", + "7", + ] { + assert!(bad.parse::().is_err(), "accepted {bad:?}"); + } +} diff --git a/native/suidhelper/tests/process/privilege.rs b/native/suidhelper/tests/process/privilege.rs new file mode 100644 index 00000000..c5bf8e35 --- /dev/null +++ b/native/suidhelper/tests/process/privilege.rs @@ -0,0 +1,19 @@ +//! `Unprivileged` is the single token every privilege transition borrows or +//! consumes, so nesting is ruled out only if at most one ever exists. Law: the +//! first `take` in a process yields it and every later `take` refuses, even +//! after the first token is dropped. + +use hyper_suidhelper::process::privilege::Unprivileged; + +#[test] +fn at_most_one_token_per_process() { + { + let first = Unprivileged::take(); + assert!(first.is_some(), "first take must yield the token"); + assert!(Unprivileged::take().is_none(), "second take while held"); + } + assert!( + Unprivileged::take().is_none(), + "take after the first is dropped" + ); +} diff --git a/native/suidhelper/tests/tools/dmsetup_parsers.rs b/native/suidhelper/tests/tools/dmsetup_parsers.rs index b05419b3..67bb53cd 100644 --- a/native/suidhelper/tests/tools/dmsetup_parsers.rs +++ b/native/suidhelper/tests/tools/dmsetup_parsers.rs @@ -6,7 +6,7 @@ //! arity. These are the contract that keeps dmsetup off arbitrary storage. use hyper_suidhelper::linux::dm::ThinId; -use hyper_suidhelper::tools::{parse_message, parse_table, ThinMessage}; +use hyper_suidhelper::tools::{parse_dm_name, parse_message, parse_table, NameError, ThinMessage}; use proptest::prelude::*; fn id(n: u32) -> ThinId { @@ -56,6 +56,8 @@ fn rejects_non_whitelisted_targets_and_devices() { "0 100 thin-pool /srv/hyper/m /dev/loop1 128 1024", // data = device "0 100 thin /dev/sda 0", // pool = arbitrary "0 100 thin /srv/hyper/pool 0", // pool = file + "0 100 thin /dev/loop0 0", // pool = loop, not dm + "0 100 thin /dev/mapper/other 0", // pool = non-hyper dm "0 100 thin /dev/mapper/hyper-pool 16777216", // id past 24 bits "0 100 thin /dev/mapper/hyper-pool 0 /dev/sda", // origin = arbitrary "0 100 thin /dev/mapper/hyper-pool 0 /dev/loop0 /dev/loop1", // two origins @@ -67,6 +69,24 @@ fn rejects_non_whitelisted_targets_and_devices() { } proptest! { + #[test] + fn dm_name_accepts_every_valid_hyper_name(suffix in "[0-9A-Za-z#+.:=@_-]{0,32}") { + let s = format!("hyper-{suffix}"); + prop_assert_eq!(parse_dm_name(&s).unwrap().to_string(), s); + } + + #[test] + fn dm_name_refuses_valid_names_we_do_not_own(s in "[0-9A-Za-z#+.:=@_][0-9A-Za-z#+.:=@_-]{0,32}") { + prop_assume!(!s.starts_with("hyper-") && !matches!(s.as_str(), "." | ".." | "control")); + prop_assert!(matches!(parse_dm_name(&s), Err(NameError::NotHyper(_)))); + } + + #[test] + fn dm_name_refuses_hyper_names_the_kernel_would_reject(pre in "[a-z]{0,8}", post in "[a-z]{0,8}") { + let s = format!("hyper-{pre}/{post}"); + prop_assert!(matches!(parse_dm_name(&s), Err(NameError::Invalid(_)))); + } + #[test] fn a_device_is_never_accepted_as_a_backing_file(rest in "[A-Za-z0-9._/-]{1,32}") { let dev = format!("/dev/{rest}"); diff --git a/native/suidhelper/tests/util/safe_dev.rs b/native/suidhelper/tests/util/safe_dev.rs index 7dec4e2f..6d1b330c 100644 --- a/native/suidhelper/tests/util/safe_dev.rs +++ b/native/suidhelper/tests/util/safe_dev.rs @@ -2,115 +2,57 @@ //! the privileged tools off arbitrary storage and blocks path tricks. Runs //! against the `hyper_suidhelper` library crate, not inline in source. -use hyper_suidhelper::util::safe_dev::{BlockDev, DmName, LoopDev}; +use hyper_suidhelper::util::safe_dev::BlockDev; use proptest::prelude::*; use std::path::Path; -// The charset of a valid `hyper-*` dm name suffix: ascii-alphanumeric plus -// `-`/`_`/`.`. Crucially excludes `/`, so a valid name can never traverse. -fn name_suffix() -> impl Strategy { - "[a-zA-Z0-9._-]{0,16}" +fn dm_suffix() -> impl Strategy { + "[0-9A-Za-z#+.:=@_-]{0,16}" } proptest! { - // LoopDev accepts exactly `/dev/loop` and preserves the path. #[test] - fn loopdev_accepts_dev_loop_n(n in any::()) { + fn accepts_loop_and_preserves_path(n in any::()) { let s = format!("/dev/loop{n}"); - let dev = s.parse::().unwrap(); - prop_assert_eq!(dev.as_ref(), Path::new(&s)); + let dev = s.parse::().unwrap(); + prop_assert_eq!(dev.path().as_path(), Path::new(&s)); } - // A `/dev/loop` whose suffix is empty or starts with a non-digit is never a - // valid loop device (covers `/dev/loop`, `/dev/loopX`, and path tricks whose - // first post-prefix byte is non-numeric). #[test] - fn loopdev_rejects_non_numeric_suffix(suffix in "([^0-9].*)?") { - let s = format!("/dev/loop{suffix}"); - prop_assert!(s.parse::().is_err()); - } - - // A digit-led suffix that then contains any non-digit (e.g. `0/../sda`, - // `1a`, `2/x`) is rejected: is_loop requires the WHOLE suffix to be digits. - #[test] - fn loopdev_rejects_digit_then_junk(n in any::(), junk in "[^0-9].*") { - let s = format!("/dev/loop{n}{junk}"); - prop_assert!(s.parse::().is_err()); - } - - // BlockDev accepts a valid loop device. - #[test] - fn blockdev_accepts_loop(n in any::()) { - let s = format!("/dev/loop{n}"); - prop_assert!(s.parse::().is_ok()); - } - - // BlockDev accepts a valid `/dev/mapper/hyper-*` dm device. - #[test] - fn blockdev_accepts_hyper_dm(suffix in name_suffix()) { + fn accepts_hyper_dm_and_preserves_path(suffix in dm_suffix()) { let s = format!("/dev/mapper/hyper-{suffix}"); - prop_assert!(s.parse::().is_ok()); + let dev = s.parse::().unwrap(); + prop_assert_eq!(dev.path().as_path(), Path::new(&s)); } // BlockDev rejects a non-hyper dm device (e.g. `/dev/mapper/cryptroot`) — // any mapper name not starting with `hyper-`. #[test] - fn blockdev_rejects_non_hyper_dm(name in "[a-z][a-z0-9._-]{0,12}") { + fn rejects_non_hyper_dm(name in "[a-z][a-z0-9._-]{0,12}") { prop_assume!(!name.starts_with("hyper-")); let s = format!("/dev/mapper/{name}"); prop_assert!(s.parse::().is_err()); } - - // DmName accepts a `hyper-*` safe name and round-trips it through Display. - #[test] - fn dmname_accepts_hyper_name(suffix in name_suffix()) { - let s = format!("hyper-{suffix}"); - let dm = s.parse::().unwrap(); - prop_assert_eq!(dm.to_string(), s); - } - - // DmName rejects any name containing a `/` — the no-traversal guarantee. - #[test] - fn dmname_rejects_any_slash(pre in name_suffix(), post in name_suffix()) { - let s = format!("hyper-{pre}/{post}"); - prop_assert!(s.parse::().is_err()); - } - - // DmName rejects a name not starting with `hyper-`. - #[test] - fn dmname_rejects_non_hyper_prefix(s in "[a-zA-Z][a-zA-Z0-9._-]{0,12}") { - prop_assume!(!s.starts_with("hyper-")); - prop_assert!(s.parse::().is_err()); - } } -// A curated set of concrete attack/edge strings that no generator is guaranteed -// to hit, asserted explicitly. These are the cases the validators exist to stop. #[test] fn rejects_known_attack_strings() { for bad in [ - "/dev/loop", // empty number - "/dev/loop0/../sda", // traversal off a loop dev - "/dev/loopX", // non-numeric - "/dev/sda", // arbitrary storage - "/dev/mapper/hyper-x/../../sda", // traversal via a hyper-looking name - "/dev/mapper/cryptroot", // non-hyper dm - "../dev/loop0", // relative + "/dev/loop", + "/dev/loop0/../sda", + "/dev/loopX", + "/dev/loop00", + "/dev/loop4294967296", + "/dev/sda", + "/dev/mapper/hyper-x/../../sda", + "/dev/mapper/hyper-x/", + "/dev/mapper/cryptroot", + "/dev/mapper/control", + "../dev/loop0", ] { - assert!(bad.parse::().is_err(), "LoopDev accepted {bad:?}"); assert!( bad.parse::().is_err(), "BlockDev accepted {bad:?}" ); } - for bad in [ - "sda", - "hyper", - "nothyper-x", - "hyper-x/y", - "hyper-../x", - "/hyper-x", - ] { - assert!(bad.parse::().is_err(), "DmName accepted {bad:?}"); - } } From 6dc2731486717f7719b13d585f672379c97f6316 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 22:55:01 -0700 Subject: [PATCH 09/17] refactor(suidhelper): derive Display/From/Into/AsRef boilerplate --- native/suidhelper/Cargo.toml | 2 +- native/suidhelper/src/fs/dev.rs | 11 +++-------- native/suidhelper/src/linux/dm/name.rs | 11 +++-------- native/suidhelper/src/linux/dm/thin.rs | 17 +++-------------- native/suidhelper/src/linux/dm/thin_dump.rs | 9 ++------- native/suidhelper/src/linux/loopdev.rs | 17 +++-------------- native/suidhelper/src/util/jailer.rs | 20 +++++--------------- 7 files changed, 20 insertions(+), 67 deletions(-) diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index dfec488c..7cf259d2 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -139,7 +139,7 @@ path = "tests/tools/dmsetup_backing.rs" [dependencies] bon = "3.9.3" clap = { version = "4", features = ["derive"] } -derive_more = { version = "2.1.1", features = ["display", "as_ref", "into", "add", "debug"] } +derive_more = { version = "2.1.1", features = ["display", "as_ref", "from", "into", "add", "debug"] } hyper-suidhelper-meta = { path = "meta" } nix = { version = "0.29", features = ["user", "fs", "dir", "process", "ioctl", "poll"] } quick-xml = { version = "0.37", features = ["serialize"] } diff --git a/native/suidhelper/src/fs/dev.rs b/native/suidhelper/src/fs/dev.rs index eaa678e9..0de4dbff 100644 --- a/native/suidhelper/src/fs/dev.rs +++ b/native/suidhelper/src/fs/dev.rs @@ -1,8 +1,9 @@ +use derive_more::Display; use nix::libc::dev_t; use nix::sys::stat::{major, makedev, minor}; -use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display)] +#[display("{major}:{minor}")] pub struct DeviceNumber { major: u64, minor: u64, @@ -33,9 +34,3 @@ impl From for dev_t { makedev(dev.major, dev.minor) } } - -impl fmt::Display for DeviceNumber { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{}", self.major, self.minor) - } -} diff --git a/native/suidhelper/src/linux/dm/name.rs b/native/suidhelper/src/linux/dm/name.rs index 01ece850..c3fe51f4 100644 --- a/native/suidhelper/src/linux/dm/name.rs +++ b/native/suidhelper/src/linux/dm/name.rs @@ -1,8 +1,8 @@ use crate::fs::path::{ IsAbsolute, SafePath, SafePathBuf, StrictComponent, StrictComponentBuf, StrictComponents, }; +use derive_more::Display; use std::ffi::OsStr; -use std::fmt; use std::str::FromStr; use thiserror::Error as ThisError; @@ -12,7 +12,8 @@ const DM_NAME_MAX: usize = 127; #[error("invalid dm device name: {0:?}")] pub struct InvalidDmName(String); -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Display)] +#[display("{}", _0.as_os_str().to_string_lossy())] pub struct DmName(StrictComponentBuf); fn is_dm_name(name: &str) -> bool { @@ -53,9 +54,3 @@ impl AsRef for DmName { self.as_os_str() } } - -impl fmt::Display for DmName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.as_os_str().to_string_lossy()) - } -} diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs index 99a77120..57dff43a 100644 --- a/native/suidhelper/src/linux/dm/thin.rs +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -1,11 +1,12 @@ use super::{Device, DmName, DmTable, Dmsetup, RemoveMode, RunError, ThinTable}; use crate::fs::path::{IsAbsolute, SafePathBuf, StrictComponents}; use crate::linux::Access; +use derive_more::{Display, Into}; use std::ffi::OsStr; -use std::fmt; use thiserror::Error as ThisError; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, Into)] +#[into(u64)] pub struct ThinId(u32); #[derive(Debug, ThisError)] @@ -24,18 +25,6 @@ impl ThinId { } } -impl From for u64 { - fn from(id: ThinId) -> Self { - u64::from(id.0) - } -} - -impl fmt::Display for ThinId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - #[derive(Debug, ThisError)] pub enum ThinPoolError { #[error(transparent)] diff --git a/native/suidhelper/src/linux/dm/thin_dump.rs b/native/suidhelper/src/linux/dm/thin_dump.rs index 79a19c6e..5d508c7a 100644 --- a/native/suidhelper/src/linux/dm/thin_dump.rs +++ b/native/suidhelper/src/linux/dm/thin_dump.rs @@ -2,6 +2,7 @@ use super::ThinId; use crate::fs::file::{IsBlockDevice, SafeFile}; use crate::process::binary::RootSafeBin; +use derive_more::Into; use serde::Deserialize; use std::io; use std::os::unix::io::AsRawFd; @@ -114,19 +115,13 @@ pub struct ThinMappings { pub ranges: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Into)] #[serde(into = "(u64, u64)")] pub struct BlockRange { pub begin: u64, pub length: u64, } -impl From for (u64, u64) { - fn from(range: BlockRange) -> Self { - (range.begin, range.length) - } -} - #[derive(Debug, Deserialize)] enum Root { #[serde(rename = "superblock")] diff --git a/native/suidhelper/src/linux/loopdev.rs b/native/suidhelper/src/linux/loopdev.rs index ec5224d2..f536853e 100644 --- a/native/suidhelper/src/linux/loopdev.rs +++ b/native/suidhelper/src/linux/loopdev.rs @@ -3,8 +3,8 @@ use super::Access; use crate::fs::dev::DeviceNumber; use crate::fs::file::{self, Any, IsBlockDevice, IsCharDevice, IsRegularFile, OpenFlags, SafeFile}; use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; +use derive_more::{Display, From}; use nix::errno::Errno; -use std::fmt; use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -80,7 +80,8 @@ impl Default for LoopInfo64 { #[error("expected /dev/loopN: {0:?}")] pub struct InvalidLoopDevNum(String); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, From)] +#[display("{DEVICE_PREFIX}{_0}")] pub struct LoopDevNum(u32); impl LoopDevNum { @@ -93,12 +94,6 @@ impl LoopDevNum { } } -impl From for LoopDevNum { - fn from(number: u32) -> Self { - Self(number) - } -} - impl FromStr for LoopDevNum { type Err = InvalidLoopDevNum; @@ -115,12 +110,6 @@ impl FromStr for LoopDevNum { } } -impl fmt::Display for LoopDevNum { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{DEVICE_PREFIX}{}", self.0) - } -} - #[derive(Debug, ThisError)] pub enum OpenError { #[error("loop device path: {0}")] diff --git a/native/suidhelper/src/util/jailer.rs b/native/suidhelper/src/util/jailer.rs index ec4dd851..cc3980d8 100644 --- a/native/suidhelper/src/util/jailer.rs +++ b/native/suidhelper/src/util/jailer.rs @@ -24,7 +24,7 @@ use crate::process::binary::{RootSafeBin, RootSafeBinPathBuf}; use crate::util::setuid_privileged; use bon::Builder; -use derive_more::Display; +use derive_more::{AsRef, Display}; use nix::errno::Errno; use std::ffi::{CString, NulError}; use std::os::fd::{AsFd, AsRawFd, RawFd}; @@ -75,7 +75,8 @@ pub fn validate_id_number(n: u32, range: (u32, u32)) -> Result { /// A VM id used as a chroot subdirectory name: `[A-Za-z0-9_-]`, length `1..=64`, /// first character not `-` (so it can never be read as a flag). No `/`, `.`, NUL, /// or whitespace can appear, so it can never traverse out of the chroot base. -#[derive(Debug, Clone, Display)] +#[derive(Debug, Clone, Display, AsRef)] +#[as_ref(str)] pub struct VmId(String); impl FromStr for VmId { @@ -159,7 +160,8 @@ impl FromStr for CgroupSetting { /// filename in `[A-Za-z0-9_.-]`. The charset excludes `/`, so the value is always /// a direct child of `/` with no extra components and no traversal; `.`/`..` as /// the whole filename are rejected explicitly. -#[derive(Debug, Clone, Display)] +#[derive(Debug, Clone, Display, AsRef)] +#[as_ref(str)] pub struct JailSock(String); impl FromStr for JailSock { @@ -184,18 +186,6 @@ impl FromStr for JailSock { } } -impl AsRef for VmId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl AsRef for JailSock { - fn as_ref(&self) -> &str { - &self.0 - } -} - /// A fully-resolved jailer invocation. The privileged fields (`jailer`, /// `firecracker`, `jail_base`, `parent_cgroup`) must come from trusted config; /// the caller-supplied fields (`vm`, `uid`, `gid`, `cgroups`, `api_sock`) must From 033de38785200f9ef7a301a636cb8ec94b3512c5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 23:00:13 -0700 Subject: [PATCH 10/17] some more cleanup --- native/suidhelper/Cargo.toml | 5 +++- native/suidhelper/src/fs/dev.rs | 8 ++----- native/suidhelper/src/fs/path.rs | 19 ++++----------- native/suidhelper/src/linux/dm/mod.rs | 10 ++++---- native/suidhelper/src/linux/dm/thin.rs | 18 ++++++++++++++ native/suidhelper/src/linux/dm/thin_dump.rs | 7 ++---- native/suidhelper/src/linux/loopdev.rs | 24 ++++--------------- native/suidhelper/src/tools/blockcopy.rs | 8 ++----- .../suidhelper/src/tools/dmsetup/message.rs | 19 +++++---------- native/suidhelper/src/tools/dmsetup/table.rs | 9 +++---- native/suidhelper/src/tools/thin_dump.rs | 9 +------ native/suidhelper/tests/linux/dm.rs | 11 ++++++++- .../suid_helper/dmsetup_properties_test.exs | 10 ++++++++ 13 files changed, 70 insertions(+), 87 deletions(-) diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 7cf259d2..dbe11a0f 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -136,10 +136,13 @@ path = "tests/config_network.rs" name = "tools_dmsetup_backing" path = "tests/tools/dmsetup_backing.rs" +[lints.clippy] +redundant_field_names = "allow" + [dependencies] bon = "3.9.3" clap = { version = "4", features = ["derive"] } -derive_more = { version = "2.1.1", features = ["display", "as_ref", "from", "into", "add", "debug"] } +derive_more = { version = "2.1.1", features = ["display", "as_ref", "constructor", "from", "into", "add", "debug"] } hyper-suidhelper-meta = { path = "meta" } nix = { version = "0.29", features = ["user", "fs", "dir", "process", "ioctl", "poll"] } quick-xml = { version = "0.37", features = ["serialize"] } diff --git a/native/suidhelper/src/fs/dev.rs b/native/suidhelper/src/fs/dev.rs index 0de4dbff..5cd46784 100644 --- a/native/suidhelper/src/fs/dev.rs +++ b/native/suidhelper/src/fs/dev.rs @@ -1,8 +1,8 @@ -use derive_more::Display; +use derive_more::{Constructor, Display}; use nix::libc::dev_t; use nix::sys::stat::{major, makedev, minor}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, Constructor)] #[display("{major}:{minor}")] pub struct DeviceNumber { major: u64, @@ -10,10 +10,6 @@ pub struct DeviceNumber { } impl DeviceNumber { - pub const fn new(major: u64, minor: u64) -> Self { - Self { major, minor } - } - pub fn major(self) -> u64 { self.major } diff --git a/native/suidhelper/src/fs/path.rs b/native/suidhelper/src/fs/path.rs index 771b9dd6..a73cd087 100644 --- a/native/suidhelper/src/fs/path.rs +++ b/native/suidhelper/src/fs/path.rs @@ -5,7 +5,6 @@ use derive_more::AsRef; use ref_cast::{ref_cast_custom, RefCastCustom}; use std::borrow::Borrow; use std::ffi::OsStr; -use std::fmt; use std::marker::PhantomData; use std::ops::Deref; use std::os::unix::ffi::OsStrExt; @@ -39,11 +38,13 @@ pub enum ComponentError { } /// A path component that isn't `.`, nor `..`, nor `/`. -#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, RefCastCustom)] +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, RefCastCustom, derive_more::Debug)] +#[debug("{:?}", self.as_os_str())] #[repr(transparent)] pub struct StrictComponent([u8]); -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, derive_more::Debug)] +#[debug("{:?}", self.as_os_str())] pub struct StrictComponentBuf(Box<[u8]>); impl StrictComponent { @@ -114,12 +115,6 @@ impl AsRef for StrictComponent { } } -impl fmt::Debug for StrictComponent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.as_os_str().fmt(f) - } -} - impl ToOwned for StrictComponent { type Owned = StrictComponentBuf; fn to_owned(&self) -> StrictComponentBuf { @@ -146,12 +141,6 @@ impl AsRef for StrictComponentBuf { } } -impl fmt::Debug for StrictComponentBuf { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - (**self).fmt(f) - } -} - #[derive(Debug, ThisError)] pub enum RelativeError { #[error("path is not under the required base directory")] diff --git a/native/suidhelper/src/linux/dm/mod.rs b/native/suidhelper/src/linux/dm/mod.rs index d5d8a773..d7e099fa 100644 --- a/native/suidhelper/src/linux/dm/mod.rs +++ b/native/suidhelper/src/linux/dm/mod.rs @@ -8,13 +8,15 @@ pub mod thin_dump; use super::Access; use crate::process::binary::RootSafeBin; +use derive_more::Constructor; pub use device::Device; pub use name::{DmName, InvalidDmName}; use std::ffi::OsStr; use std::io; pub use table::{DmTable, LinearTable, Persistence, SnapshotTable, ThinPoolTable, ThinTable}; pub use thin::{ - Active, Inactive, SnapshotError, Thin, ThinId, ThinIdOutOfRange, ThinPool, ThinPoolError, + Active, Inactive, ParseThinIdError, SnapshotError, Thin, ThinId, ThinIdOutOfRange, ThinPool, + ThinPoolError, }; use thiserror::Error; @@ -47,16 +49,12 @@ pub enum RemoveMode { } /// Type-safe interface to `dmsetup`. -#[derive(Debug)] +#[derive(Debug, Constructor)] pub struct Dmsetup { bin: RootSafeBin<"dmsetup">, } impl Dmsetup { - pub fn new(bin: RootSafeBin<"dmsetup">) -> Self { - Self { bin } - } - pub fn create( &self, name: &DmName, diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs index 57dff43a..3e3c0dbb 100644 --- a/native/suidhelper/src/linux/dm/thin.rs +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -3,6 +3,8 @@ use crate::fs::path::{IsAbsolute, SafePathBuf, StrictComponents}; use crate::linux::Access; use derive_more::{Display, Into}; use std::ffi::OsStr; +use std::num::ParseIntError; +use std::str::FromStr; use thiserror::Error as ThisError; #[derive(Debug, Clone, Copy, PartialEq, Eq, Display, Into)] @@ -13,6 +15,14 @@ pub struct ThinId(u32); #[error("thin device id {0} exceeds the 24-bit limit")] pub struct ThinIdOutOfRange(u32); +#[derive(Debug, ThisError)] +pub enum ParseThinIdError { + #[error("thin device id is not an integer: {0}")] + Int(#[from] ParseIntError), + #[error(transparent)] + OutOfRange(#[from] ThinIdOutOfRange), +} + impl ThinId { pub const MAX: u32 = (1 << 24) - 1; @@ -25,6 +35,14 @@ impl ThinId { } } +impl FromStr for ThinId { + type Err = ParseThinIdError; + + fn from_str(s: &str) -> Result { + Ok(Self::new(s.parse()?)?) + } +} + #[derive(Debug, ThisError)] pub enum ThinPoolError { #[error(transparent)] diff --git a/native/suidhelper/src/linux/dm/thin_dump.rs b/native/suidhelper/src/linux/dm/thin_dump.rs index 5d508c7a..52c48ac2 100644 --- a/native/suidhelper/src/linux/dm/thin_dump.rs +++ b/native/suidhelper/src/linux/dm/thin_dump.rs @@ -2,7 +2,7 @@ use super::ThinId; use crate::fs::file::{IsBlockDevice, SafeFile}; use crate::process::binary::RootSafeBin; -use derive_more::Into; +use derive_more::{Constructor, Into}; use serde::Deserialize; use std::io; use std::os::unix::io::AsRawFd; @@ -36,15 +36,12 @@ pub enum MappingsError { Truncated { expected: u64, got: u64 }, } +#[derive(Constructor)] pub struct ThinDump { bin: RootSafeBin<"thin_dump">, } impl ThinDump { - pub fn new(bin: RootSafeBin<"thin_dump">) -> Self { - Self { bin } - } - pub fn dump( &self, metadata: &SafeFile, diff --git a/native/suidhelper/src/linux/loopdev.rs b/native/suidhelper/src/linux/loopdev.rs index f536853e..717364d9 100644 --- a/native/suidhelper/src/linux/loopdev.rs +++ b/native/suidhelper/src/linux/loopdev.rs @@ -5,6 +5,7 @@ use crate::fs::file::{self, Any, IsBlockDevice, IsCharDevice, IsRegularFile, Ope use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; use derive_more::{Display, From}; use nix::errno::Errno; +use smart_default::SmartDefault; use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -27,6 +28,7 @@ mod ioctl { nix::ioctl_write_ptr_bad!(configure, 0x4C0A, LoopConfig); } +#[derive(SmartDefault)] #[repr(C)] struct LoopInfo64 { lo_device: u64, @@ -38,7 +40,9 @@ struct LoopInfo64 { lo_encrypt_type: u32, lo_encrypt_key_size: u32, lo_flags: u32, + #[default([0; 64])] lo_file_name: [u8; 64], + #[default([0; 64])] lo_crypt_name: [u8; 64], lo_encrypt_key: [u8; 32], lo_init: [u64; 2], @@ -56,26 +60,6 @@ struct LoopConfig { const _: () = assert!(std::mem::size_of::() == 232); const _: () = assert!(std::mem::size_of::() == 304); -impl Default for LoopInfo64 { - fn default() -> Self { - Self { - lo_device: 0, - lo_inode: 0, - lo_rdevice: 0, - lo_offset: 0, - lo_sizelimit: 0, - lo_number: 0, - lo_encrypt_type: 0, - lo_encrypt_key_size: 0, - lo_flags: 0, - lo_file_name: [0; 64], - lo_crypt_name: [0; 64], - lo_encrypt_key: [0; 32], - lo_init: [0; 2], - } - } -} - #[derive(Debug, ThisError)] #[error("expected /dev/loopN: {0:?}")] pub struct InvalidLoopDevNum(String); diff --git a/native/suidhelper/src/tools/blockcopy.rs b/native/suidhelper/src/tools/blockcopy.rs index e79353a7..056cef6e 100644 --- a/native/suidhelper/src/tools/blockcopy.rs +++ b/native/suidhelper/src/tools/blockcopy.rs @@ -8,6 +8,7 @@ use super::IsTool; use crate::util::safe_dev::BlockDev; use clap::Args; +use derive_more::Constructor; use serde::{Deserialize, Serialize}; use std::fs::{File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -113,16 +114,11 @@ fn read_full(r: &mut impl Read, buf: &mut [u8]) -> io::Result { Ok(filled) } +#[derive(Constructor)] pub struct Blockcopy { args: BlockcopyArgs, } -impl Blockcopy { - pub fn new(args: BlockcopyArgs) -> Self { - Self { args } - } -} - impl IsTool for Blockcopy { type Args = BlockcopyArgs; type Output = CopyStats; diff --git a/native/suidhelper/src/tools/dmsetup/message.rs b/native/suidhelper/src/tools/dmsetup/message.rs index 691c211e..0cd2eddd 100644 --- a/native/suidhelper/src/tools/dmsetup/message.rs +++ b/native/suidhelper/src/tools/dmsetup/message.rs @@ -1,4 +1,4 @@ -use crate::linux::dm::{ThinId, ThinIdOutOfRange}; +use crate::linux::dm::{ParseThinIdError, ThinId}; use thiserror::Error as ThisError; /// A thin-pool message we permit: provision, snapshot, or drop a thin device by id, or pin/unpin the pool's metadata snapshot. @@ -16,24 +16,17 @@ pub enum MessageError { #[error("unsupported thin-pool message: {0}")] Malformed(String), #[error(transparent)] - ThinId(#[from] ThinIdOutOfRange), + ThinId(#[from] ParseThinIdError), } pub fn parse_message(s: &str) -> Result { - let id = |t: &str| -> Result { - let raw = t - .parse() - .map_err(|_| MessageError::Malformed(s.to_string()))?; - Ok(ThinId::new(raw)?) - }; - match s.split_whitespace().collect::>().as_slice() { - ["create_thin", i] => Ok(ThinMessage::CreateThin(id(i)?)), + ["create_thin", i] => Ok(ThinMessage::CreateThin(i.parse()?)), ["create_snap", new, orig] => Ok(ThinMessage::CreateSnap { - id: id(new)?, - origin: id(orig)?, + id: new.parse()?, + origin: orig.parse()?, }), - ["delete", i] => Ok(ThinMessage::Delete(id(i)?)), + ["delete", i] => Ok(ThinMessage::Delete(i.parse()?)), ["reserve_metadata_snap"] => Ok(ThinMessage::ReserveMetadataSnap), ["release_metadata_snap"] => Ok(ThinMessage::ReleaseMetadataSnap), _ => Err(MessageError::Malformed(s.to_string())), diff --git a/native/suidhelper/src/tools/dmsetup/table.rs b/native/suidhelper/src/tools/dmsetup/table.rs index fb50da73..028f2983 100644 --- a/native/suidhelper/src/tools/dmsetup/table.rs +++ b/native/suidhelper/src/tools/dmsetup/table.rs @@ -3,7 +3,7 @@ use super::name::{parse_dm_name, NameError}; use crate::config::Config; use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; use crate::linux::dm::{ - DmName, DmTable, LinearTable, Persistence, SnapshotTable, ThinId, ThinIdOutOfRange, + DmName, DmTable, LinearTable, ParseThinIdError, Persistence, SnapshotTable, ThinId, ThinPoolTable, ThinTable, }; use crate::linux::loopdev::LoopDevice; @@ -30,7 +30,7 @@ pub enum TableError { #[error(transparent)] Backing(#[from] BackingError), #[error(transparent)] - ThinId(#[from] ThinIdOutOfRange), + ThinId(#[from] ParseThinIdError), } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] @@ -225,9 +225,6 @@ impl fmt::Display for TableSpec { pub fn parse_table(s: &str) -> Result, TableError> { let malformed = || TableError::Malformed(s.to_string()); let number = |t: &str| t.parse::().map_err(|_| malformed()); - let thin_id = |t: &str| -> Result { - Ok(ThinId::new(t.parse().map_err(|_| malformed())?)?) - }; let is_device = |t: &str| t.starts_with("/dev/"); let file = |t: &str| -> Result { if is_device(t) { @@ -279,7 +276,7 @@ pub fn parse_table(s: &str) -> Result, TableError> { ["0", sectors, "thin", pool, id, origin @ ..] if origin.len() <= 1 => TableSpec::Thin { sectors: number(sectors)?, pool: dm_device(pool)?, - id: thin_id(id)?, + id: id.parse()?, origin: origin.first().map(|o| source(o)).transpose()?, }, _ => return Err(malformed()), diff --git a/native/suidhelper/src/tools/thin_dump.rs b/native/suidhelper/src/tools/thin_dump.rs index d0cf2a06..5ecf4147 100644 --- a/native/suidhelper/src/tools/thin_dump.rs +++ b/native/suidhelper/src/tools/thin_dump.rs @@ -36,17 +36,10 @@ pub struct ThinDumpArgs { #[arg(long)] meta: LoopDevNum, /// The thin device id whose provisioned ranges to extract. - #[arg(long, value_parser = parse_thin_id)] + #[arg(long)] dev_id: ThinId, } -fn parse_thin_id(s: &str) -> Result { - let raw: u32 = s - .parse() - .map_err(|e: std::num::ParseIntError| e.to_string())?; - ThinId::new(raw).map_err(|e| e.to_string()) -} - pub struct ThinDump { thin_dump: dump::ThinDump, args: ThinDumpArgs, diff --git a/native/suidhelper/tests/linux/dm.rs b/native/suidhelper/tests/linux/dm.rs index a1fcabd3..f32b166b 100644 --- a/native/suidhelper/tests/linux/dm.rs +++ b/native/suidhelper/tests/linux/dm.rs @@ -61,10 +61,19 @@ proptest! { Ok(id) => { prop_assert!(n <= ThinId::MAX); prop_assert_eq!(id.to_string(), n.to_string()); + prop_assert_eq!(n.to_string().parse::().unwrap(), id); + } + Err(_) => { + prop_assert!(n > ThinId::MAX); + prop_assert!(n.to_string().parse::().is_err()); } - Err(_) => prop_assert!(n > ThinId::MAX), } } + + #[test] + fn thin_id_parse_rejects_non_integers(s in "[^0-9+]*|[0-9]+[^0-9]+[0-9]*") { + prop_assert!(s.parse::().is_err()); + } } #[test] diff --git a/test/hyper/suid_helper/dmsetup_properties_test.exs b/test/hyper/suid_helper/dmsetup_properties_test.exs index a7e49d81..b1be0029 100644 --- a/test/hyper/suid_helper/dmsetup_properties_test.exs +++ b/test/hyper/suid_helper/dmsetup_properties_test.exs @@ -16,6 +16,16 @@ defmodule Hyper.SuidHelper.DmsetupPropertiesTest do defp pos, do: integer(1..1_000_000_000) defp nonneg, do: integer(0..1_000_000_000) + property "linear_table maps the whole origin from offset 0" do + check all(origin <- dev(), sectors <- pos()) do + table = Dmsetup.linear_table(origin, sectors) + + assert ["0", s, "linear", o, "0"] = String.split(table) + assert s == "#{sectors}" + assert o == origin + end + end + property "snapshot_table places origin, cow, persistent flag, and chunk in order" do check all(origin <- dev(), cow <- dev(), sectors <- pos(), chunk <- pos()) do table = Dmsetup.snapshot_table(origin, cow, sectors, chunk) From 61294fa78e062c7a95571aca7bd0bf5e583a10e5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 23:41:04 -0700 Subject: [PATCH 11/17] some massive cleanup --- native/suidhelper/Cargo.toml | 5 + native/suidhelper/src/main.rs | 19 ++- native/suidhelper/src/process/binary.rs | 11 +- native/suidhelper/src/process/privilege.rs | 151 +++++++++++++----- native/suidhelper/src/tools/blockcopy.rs | 3 +- native/suidhelper/src/tools/blockdev.rs | 3 +- .../src/tools/chroot_jail/grant_api.rs | 10 +- .../src/tools/chroot_jail/grant_vsock.rs | 10 +- .../suidhelper/src/tools/chroot_jail/mod.rs | 11 +- .../src/tools/chroot_jail/prepare.rs | 10 +- .../src/tools/chroot_jail/remove.rs | 10 +- native/suidhelper/src/tools/dmsetup/mod.rs | 3 +- native/suidhelper/src/tools/jailer.rs | 5 +- native/suidhelper/src/tools/mod.rs | 26 +-- native/suidhelper/src/tools/network/exec.rs | 20 +-- .../suidhelper/src/tools/network/host_init.rs | 12 +- native/suidhelper/src/tools/network/mod.rs | 11 +- .../suidhelper/src/tools/network/prepare.rs | 12 +- .../suidhelper/src/tools/network/teardown.rs | 12 +- .../src/tools/network/teardown_orphan.rs | 10 +- native/suidhelper/src/tools/thin_dump.rs | 3 +- native/suidhelper/src/util/jailer.rs | 74 ++------- native/suidhelper/src/util/mod.rs | 1 - .../suidhelper/src/util/setuid_privileged.rs | 119 -------------- native/suidhelper/tests/e2e/fake_jailer.rs | 20 +++ native/suidhelper/tests/e2e/jailer.rs | 34 +--- native/suidhelper/tests/process/privilege.rs | 11 +- 27 files changed, 273 insertions(+), 343 deletions(-) delete mode 100644 native/suidhelper/src/util/setuid_privileged.rs create mode 100644 native/suidhelper/tests/e2e/fake_jailer.rs diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index dbe11a0f..ed8a2388 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -21,6 +21,11 @@ insecure_test_seams = [] name = "hyper-suidhelper" path = "src/main.rs" +[[bin]] +name = "fake-jailer" +path = "tests/e2e/fake_jailer.rs" +required-features = ["insecure_test_seams"] + # Integration tests mirror the `src/` module tree under `tests/`. Cargo only # auto-discovers test files at the top level of `tests/`, so each nested file # needs an explicit target pointing at its path. diff --git a/native/suidhelper/src/main.rs b/native/suidhelper/src/main.rs index e6fee50c..1ecf6367 100644 --- a/native/suidhelper/src/main.rs +++ b/native/suidhelper/src/main.rs @@ -15,12 +15,14 @@ use clap::{Parser, Subcommand}; use hyper_suidhelper::config; +use hyper_suidhelper::process::privilege::{self, Unprivileged}; use hyper_suidhelper::tools::jailer::{self, JailerArgs}; use hyper_suidhelper::tools::Tool; -use hyper_suidhelper::util::setuid_privileged::{self, Privileged}; use serde::Serialize; use std::path::PathBuf; +hyper_suidhelper::setuid_main!(run); + #[derive(Parser)] #[command( name = "hyper-suidhelper", @@ -77,8 +79,8 @@ struct SysTest { } impl SysTest { - fn perform() -> Result { - Privileged::smoke_test()?; + fn perform(token: &mut Unprivileged) -> Result { + drop(token.elevate()?); Ok(Self { sys_test: "ok", hyper_base: config::Config::get().hyper_base().to_path_buf(), @@ -86,7 +88,7 @@ impl SysTest { } } -fn main() { +fn run(mut token: Unprivileged) { // The gate must resolve before clap parsing: some subcommands' value_parsers // (losetup attach's backing-file gate) consult the config while clap parses // argv, and the config consults this gate. Resolving the gate first ensures @@ -112,12 +114,15 @@ fn main() { // in place, so on success it never returns and never emits JSON, and on // failure it reports to stderr and exits before reaching the JSON pipeline. let output = match command { - Command::Tool(tool) => tool.run().map(Output::Tool).map_err(|e| e.to_string()), - Command::SysTest => SysTest::perform() + Command::Tool(tool) => tool + .run(&mut token) + .map(Output::Tool) + .map_err(|e| e.to_string()), + Command::SysTest => SysTest::perform(&mut token) .map(Output::SysTest) .map_err(|e| e.to_string()), Command::Jailer(args) => { - let e = jailer::run(args).expect_err("jailer::run only returns on error"); + let e = jailer::run(args, token).expect_err("jailer::run only returns on error"); eprintln!("{e}"); // _exit bypasses atexit handlers; safe because we are permanently root // at this point and must not run any cleanup registered before escalation. diff --git a/native/suidhelper/src/process/binary.rs b/native/suidhelper/src/process/binary.rs index 9265ef0e..d9cd81fc 100644 --- a/native/suidhelper/src/process/binary.rs +++ b/native/suidhelper/src/process/binary.rs @@ -3,8 +3,7 @@ use crate::fs::file::{self, IsRegularFile, OpenFlags, OwnerOnlyWritable, RootOwn use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; use nix::errno::Errno; use nix::fcntl::{fcntl, FcntlArg, FdFlag}; -use std::convert::Infallible; -use std::ffi::{CStr, OsStr}; +use std::ffi::OsStr; use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; @@ -83,14 +82,6 @@ impl RootSafeBin { cmd.arg0(NAME); cmd } - - pub fn exec, SE: AsRef>( - &self, - args: &[SA], - env: &[SE], - ) -> nix::Result { - nix::unistd::fexecve(self.0.as_raw_fd(), args, env) - } } impl AsFd for RootSafeBin { diff --git a/native/suidhelper/src/process/privilege.rs b/native/suidhelper/src/process/privilege.rs index 174ecdf9..50cd9ea5 100644 --- a/native/suidhelper/src/process/privilege.rs +++ b/native/suidhelper/src/process/privilege.rs @@ -1,18 +1,11 @@ -//! Setuid privilege transitions. -//! -//! A program using this utility runs with privileges lowered to that of the caller. Root is only -//! reachable through the [`Unprivileged`] token. -//! -//! [`Unprivileged::elevate`] returns a [`Root`] guard. While it's in scope, the the process is -//! euid/egid 0. Dropping lowers it back. -//! -//! The helper runs with its effective ids lowered to the caller's. Root is only -//! reachable through the single [`Unprivileged`] token, taken once in `main`: +//! Setuid privilege utilities. use derive_more::Display; use nix::errno::Errno; use nix::libc::{self, c_uint}; -use nix::unistd::{getgid, getuid, setegid, seteuid, setgroups, setresgid, setresuid, Gid, Uid}; +use nix::unistd::{ + getegid, geteuid, getgid, getuid, setegid, seteuid, setgroups, setresgid, setresuid, Gid, Uid, +}; use std::io; use std::marker::PhantomData; use std::os::unix::process::CommandExt; @@ -22,6 +15,42 @@ use thiserror::Error as ThisError; static TAKEN: AtomicBool = AtomicBool::new(false); +#[macro_export] +macro_rules! setuid_main { + ($entry:path) => { + #[used] + #[link_section = ".init_array"] + static __HYPER_DROP_PRIVILEGES: extern "C" fn() = $crate::process::privilege::drop_to_real; + + fn main() { + let token = match $crate::process::privilege::Unprivileged::take() { + Ok(token) => token, + Err(e) => { + ::std::eprintln!("hyper-suidhelper: {e}"); + ::std::process::exit(2); + } + }; + $entry(token) + } + }; +} + +/// Drop to the real user id. This runs before the Rust runtime is initialized in `.init_array`. +/// +/// You shouldn't need to call this yourself. Use `setuid_main!`. +/// +/// Exits if it fails. +pub extern "C" fn drop_to_real() { + if setegid(getgid()).is_err() || seteuid(getuid()).is_err() { + const MSG: &[u8] = b"hyper-suidhelper: failed to drop privileges at startup\n"; + // SAFETY: raw FFI to async-signal-safe libc calls; MSG is a static buffer. + unsafe { + let _ = libc::write(2, MSG.as_ptr().cast(), MSG.len()); + libc::_exit(1); + } + } +} + const ROOT_UID: Uid = Uid::from_raw(0); const ROOT_GID: Gid = Gid::from_raw(0); const FIRST_INHERITED_FD: c_uint = 3; @@ -54,6 +83,14 @@ pub enum Error { MarkCloexec(#[source] Errno), } +#[derive(Debug, ThisError)] +pub enum TakeError { + #[error("the privilege token was already taken")] + AlreadyTaken, + #[error("still privileged at startup (euid {euid}, egid {egid}): the pre-main privilege drop did not run")] + StillPrivileged { euid: Uid, egid: Gid }, +} + #[derive(Debug)] pub struct Unprivileged { _seal: (), @@ -66,53 +103,87 @@ pub struct Root<'a> { } impl Unprivileged { - pub fn take() -> Option { - (!TAKEN.swap(true, Ordering::Relaxed)).then_some(Self { _seal: () }) + #[doc(hidden)] + pub fn take() -> Result { + let (euid, egid) = (geteuid(), getegid()); + if euid != getuid() || egid != getgid() { + return Err(TakeError::StillPrivileged { euid, egid }); + } + + if TAKEN.swap(true, Ordering::Relaxed) { + return Err(TakeError::AlreadyTaken); + } + + Ok(Self { _seal: () }) } pub fn elevate(&mut self) -> Result, Error> { seteuid(ROOT_UID).map_err(Error::RaiseUser)?; + setegid(ROOT_GID).map_err(Error::RaiseGroup)?; + let root = Root { _token: PhantomData, }; - setegid(ROOT_GID).map_err(Error::RaiseGroup)?; + Ok(root) } } pub trait RootCommandExt { - fn exec_as_root(&mut self, token: Unprivileged) -> io::Error; + /// Replace this process with the command, as genuine root (real, effective + /// and saved ids all 0, groups `[0]`, fds above stdio close-on-exec). + /// Consumes the token. Exits on failure, possibly already root. + fn exec_as_root(&mut self, token: Unprivileged) -> !; + + /// Make children spawned from this command run as genuine root, like + /// [`exec_as_root`](Self::exec_as_root). Only the child is affected. + fn spawn_as_root(&mut self, root: &Root<'_>) -> &mut Self; +} + +fn seal_root() -> io::Result<()> { + // SAFETY: close_range takes no memory operands. + let rc = unsafe { + libc::syscall( + libc::SYS_close_range, + FIRST_INHERITED_FD, + c_uint::MAX, + libc::CLOSE_RANGE_CLOEXEC, + ) + }; + + if rc != 0 { + return Err(io::Error::other(Error::MarkCloexec(Errno::last()))); + } + + let seal = |step| move |errno| io::Error::other(Error::Seal { step, errno }); + + seteuid(ROOT_UID).map_err(seal(SealStep::Euid))?; + setresgid(ROOT_GID, ROOT_GID, ROOT_GID).map_err(seal(SealStep::Gids))?; + setgroups(&[ROOT_GID]).map_err(seal(SealStep::Groups))?; + setresuid(ROOT_UID, ROOT_UID, ROOT_UID).map_err(seal(SealStep::Uids)) } impl RootCommandExt for Command { - fn exec_as_root(&mut self, token: Unprivileged) -> io::Error { + fn exec_as_root(&mut self, token: Unprivileged) -> ! { let Unprivileged { _seal: () } = token; - let hook = || { - // SAFETY: close_range takes no memory operands. - let rc = unsafe { - libc::syscall( - libc::SYS_close_range, - FIRST_INHERITED_FD, - c_uint::MAX, - libc::CLOSE_RANGE_CLOEXEC, - ) - }; - if rc != 0 { - return Err(io::Error::other(Error::MarkCloexec(Errno::last()))); - } - - let seal = |step| move |errno| io::Error::other(Error::Seal { step, errno }); - seteuid(ROOT_UID).map_err(seal(SealStep::Euid))?; - setresgid(ROOT_GID, ROOT_GID, ROOT_GID).map_err(seal(SealStep::Gids))?; - setgroups(&[ROOT_GID]).map_err(seal(SealStep::Groups))?; - setresuid(ROOT_UID, ROOT_UID, ROOT_UID).map_err(seal(SealStep::Uids)) - }; self.env_clear(); - // SAFETY: `exec` runs this hook in the current process (there is no - // fork), right before `execve`. - unsafe { self.pre_exec(hook) }; - self.exec() + // SAFETY: `exec` runs this hook in the current process (there is no fork), right before + // `execve`. + unsafe { self.pre_exec(seal_root) }; + let err = self.exec(); + + eprintln!("hyper-suidhelper: exec as root failed: {err}"); + // SAFETY: `_exit` terminates without unwinding or running atexit handlers. + unsafe { libc::_exit(2) } + } + + fn spawn_as_root(&mut self, _root: &Root<'_>) -> &mut Self { + self.env_clear(); + // SAFETY: the hook runs in the forked child right before `execve`, and `seal_root` makes + // only async-signal-safe syscalls and allocates nothing on its success path. + unsafe { self.pre_exec(seal_root) }; + self } } diff --git a/native/suidhelper/src/tools/blockcopy.rs b/native/suidhelper/src/tools/blockcopy.rs index 056cef6e..0042fcd6 100644 --- a/native/suidhelper/src/tools/blockcopy.rs +++ b/native/suidhelper/src/tools/blockcopy.rs @@ -6,6 +6,7 @@ //! ranges are trusted as the exact divergence. use super::IsTool; +use crate::process::privilege::Root; use crate::util::safe_dev::BlockDev; use clap::Args; use derive_more::Constructor; @@ -124,7 +125,7 @@ impl IsTool for Blockcopy { type Output = CopyStats; type RunT = io::Result; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { let mut src = File::open(&self.args.src)?; let mut dst = OpenOptions::new().write(true).open(&self.args.dst)?; let chunk = self.args.chunk_bytes as usize; diff --git a/native/suidhelper/src/tools/blockdev.rs b/native/suidhelper/src/tools/blockdev.rs index fd25bc67..1715d9c9 100644 --- a/native/suidhelper/src/tools/blockdev.rs +++ b/native/suidhelper/src/tools/blockdev.rs @@ -1,6 +1,7 @@ use super::IsTool; use crate::fs::file::{self, Any, IsBlockDevice, OpenFlags, SafeFile}; use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::process::privilege::Root; use crate::util::safe_dev::BlockDev; use clap::Args; use serde::Serialize; @@ -54,7 +55,7 @@ impl IsTool for Blockdev { type Output = BlockdevOut; type RunT = Result; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { let dev: &std::path::Path = self.path.as_ref(); let real = std::fs::canonicalize(dev).map_err(|source| Error::Resolve { path: dev.to_path_buf(), diff --git a/native/suidhelper/src/tools/chroot_jail/grant_api.rs b/native/suidhelper/src/tools/chroot_jail/grant_api.rs index 39e0c065..82e68cfb 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant_api.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant_api.rs @@ -36,6 +36,7 @@ use crate::config::Config; use crate::fs::dir::SafeDir; use crate::fs::file::{self, OpenFlags}; use crate::fs::path::{self, IsAbsolute, SafePathBuf, StrictComponent, StrictComponents}; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::IsTool; use clap::Args; use nix::errno::Errno; @@ -95,8 +96,11 @@ pub struct GrantApiArgs { } /// Run the `grant-api` op in its own privileged scope (returns its serialized `Value`). -pub fn run(args: GrantApiArgs) -> Result { - GrantApi { args }.run() +pub fn run( + args: GrantApiArgs, + token: &mut Unprivileged, +) -> Result { + GrantApi { args }.run(token) } struct GrantApi { @@ -108,7 +112,7 @@ impl IsTool for GrantApi { type Output = GrantOut; type RunT = Result; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { grant_api_under(&Config::get().jail_base(), &self.args.socket) } diff --git a/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs b/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs index 2ffd04fc..8a7c6843 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs @@ -20,6 +20,7 @@ use crate::config::Config; use crate::fs::dir::SafeDir; use crate::fs::file::{self, OpenFlags}; use crate::fs::path::{self, IsAbsolute, SafePathBuf, StrictComponent, StrictComponents}; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::IsTool; use clap::Args; use nix::errno::Errno; @@ -80,8 +81,11 @@ pub struct GrantVsockArgs { } /// Run the `grant-vsock` op in its own privileged scope (returns its serialized `Value`). -pub fn run(args: GrantVsockArgs) -> Result { - GrantVsock { args }.run() +pub fn run( + args: GrantVsockArgs, + token: &mut Unprivileged, +) -> Result { + GrantVsock { args }.run(token) } struct GrantVsock { @@ -93,7 +97,7 @@ impl IsTool for GrantVsock { type Output = GrantOut; type RunT = Result; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { grant_vsock_under(&Config::get().jail_base(), &self.args.socket) } diff --git a/native/suidhelper/src/tools/chroot_jail/mod.rs b/native/suidhelper/src/tools/chroot_jail/mod.rs index a53665d7..08b9d50d 100644 --- a/native/suidhelper/src/tools/chroot_jail/mod.rs +++ b/native/suidhelper/src/tools/chroot_jail/mod.rs @@ -12,6 +12,7 @@ pub use grant_vsock::GrantVsockArgs; pub use prepare::PrepareArgs; pub use remove::RemoveArgs; +use crate::process::privilege::Unprivileged; use clap::Subcommand; #[derive(Subcommand)] @@ -30,12 +31,12 @@ impl ChrootJailOp { /// Route to the selected nested tool. `chroot-jail` itself carries no /// behaviour; each op runs in its own privileged scope and returns its own /// serialized `Value`. - pub fn run(self) -> Result { + pub fn run(self, token: &mut Unprivileged) -> Result { match self { - ChrootJailOp::Prepare(args) => prepare::run(args), - ChrootJailOp::Remove(args) => remove::run(args), - ChrootJailOp::GrantApi(args) => grant_api::run(args), - ChrootJailOp::GrantVsock(args) => grant_vsock::run(args), + ChrootJailOp::Prepare(args) => prepare::run(args, token), + ChrootJailOp::Remove(args) => remove::run(args, token), + ChrootJailOp::GrantApi(args) => grant_api::run(args, token), + ChrootJailOp::GrantVsock(args) => grant_vsock::run(args, token), } } } diff --git a/native/suidhelper/src/tools/chroot_jail/prepare.rs b/native/suidhelper/src/tools/chroot_jail/prepare.rs index ba4e03dd..e3c67148 100644 --- a/native/suidhelper/src/tools/chroot_jail/prepare.rs +++ b/native/suidhelper/src/tools/chroot_jail/prepare.rs @@ -2,6 +2,7 @@ //! `chroot-jail prepare`: stage the kernel file and create the rootfs device node //! inside a VM chroot, via the [`ChrootJail`] builder. +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::IsTool; use crate::util::chroot_jail::{self, ChrootJail}; use crate::util::safe_dev::BlockDev; @@ -36,8 +37,11 @@ pub enum PrepareOut { } /// Run the `prepare` op in its own privileged scope (returns its serialized `Value`). -pub fn run(args: PrepareArgs) -> Result { - Prepare { args }.run() +pub fn run( + args: PrepareArgs, + token: &mut Unprivileged, +) -> Result { + Prepare { args }.run(token) } struct Prepare { @@ -49,7 +53,7 @@ impl IsTool for Prepare { type Output = PrepareOut; type RunT = Result<(), chroot_jail::Error>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { let args = &self.args; ChrootJail::new(args.chroot.clone(), args.uid, args.gid) .with_kernel(args.kernel.clone()) diff --git a/native/suidhelper/src/tools/chroot_jail/remove.rs b/native/suidhelper/src/tools/chroot_jail/remove.rs index c47016ad..14381375 100644 --- a/native/suidhelper/src/tools/chroot_jail/remove.rs +++ b/native/suidhelper/src/tools/chroot_jail/remove.rs @@ -18,6 +18,7 @@ use crate::fs::dir::{self, SafeDir}; use crate::fs::file::{self, OpenFlags}; use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; use crate::linux::cgroup::{self, Cgroup}; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::IsTool; use clap::Args; use nix::errno::Errno; @@ -85,8 +86,11 @@ pub enum RemoveOut { } /// Run the `remove` op in its own privileged scope (returns its serialized `Value`). -pub fn run(args: RemoveArgs) -> Result { - Remove { args }.run() +pub fn run( + args: RemoveArgs, + token: &mut Unprivileged, +) -> Result { + Remove { args }.run(token) } struct Remove { @@ -98,7 +102,7 @@ impl IsTool for Remove { type Output = RemoveOut; type RunT = Result<(), Error>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { // Cgroup first: it SIGKILLs any firecracker still alive in the leaf, so the // chroot teardown below is not yanking files out from under a live process. remove_cgroup_under(Path::new(cgroup::ROOT), &self.args.cgroup)?; diff --git a/native/suidhelper/src/tools/dmsetup/mod.rs b/native/suidhelper/src/tools/dmsetup/mod.rs index f1021b13..e2bbd058 100644 --- a/native/suidhelper/src/tools/dmsetup/mod.rs +++ b/native/suidhelper/src/tools/dmsetup/mod.rs @@ -8,6 +8,7 @@ use crate::linux::dm::{self, DmName, RemoveMode}; use crate::linux::loopdev::LoopControl; use crate::linux::Access; use crate::process::binary::RootSafeBin; +use crate::process::privilege::Root; pub use backing::{BackingError, BackingFile}; use clap::{Args, Subcommand}; pub use message::{parse_message, MessageError, ThinMessage}; @@ -104,7 +105,7 @@ impl IsTool for Dmsetup { type Output = DmsetupOut; type RunT = Result>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { let dm = &self.dmsetup; Ok(match &self.op { DmOp::Create { diff --git a/native/suidhelper/src/tools/jailer.rs b/native/suidhelper/src/tools/jailer.rs index 615f320b..e25cb663 100644 --- a/native/suidhelper/src/tools/jailer.rs +++ b/native/suidhelper/src/tools/jailer.rs @@ -12,6 +12,7 @@ //! the trusted-config resolution that feeds them. use crate::config::{BinError, Config}; +use crate::process::privilege::Unprivileged; use crate::util::jailer::Jailer; use clap::Args; use std::path::PathBuf; @@ -51,7 +52,7 @@ pub struct JailerArgs { /// On success this never returns (the process image is replaced); the `Ok` arm /// is therefore [`std::convert::Infallible`]. Every failure is returned as /// [`Error`] for the caller to print and exit non-zero. -pub fn run(args: JailerArgs) -> Result { +pub fn run(args: JailerArgs, token: Unprivileged) -> Result { let config = Config::get(); // Resolve everything that can fail as the REAL uid, before any privilege is @@ -84,6 +85,6 @@ pub fn run(args: JailerArgs) -> Result { .api_sock(&args.api_sock) .maybe_netns(netns.as_deref()) .build() - .invoke() + .invoke(token) .map_err(Error::Jail) } diff --git a/native/suidhelper/src/tools/mod.rs b/native/suidhelper/src/tools/mod.rs index 0d3def24..4c0aaa15 100644 --- a/native/suidhelper/src/tools/mod.rs +++ b/native/suidhelper/src/tools/mod.rs @@ -23,7 +23,7 @@ pub use network::NetworkOp; pub use thin_dump::{ThinDump, ThinDumpArgs}; use crate::config::Config; -use crate::util::setuid_privileged::{self, Privileged}; +use crate::process::privilege::{self, Root, Unprivileged}; use clap::Subcommand; use serde::Serialize; use thiserror::Error as ThisError; @@ -34,7 +34,7 @@ use thiserror::Error as ThisError; #[derive(Debug, ThisError)] pub enum Error { #[error(transparent)] - Privilege(#[from] setuid_privileged::Error), + Privilege(#[from] privilege::Error), /// A tool's own error (operand validation or execution), boxed so this layer /// stays decoupled from each tool's concrete error type. #[error(transparent)] @@ -52,7 +52,7 @@ pub trait IsTool { /// Execute the privileged part of the tool - normally invoking `Command`. /// This is the only code that runs as root (see `run`). - fn run_privileged(&self) -> Self::RunT; + fn run_privileged(&self, root: &Root<'_>) -> Self::RunT; /// Parse the result of `run_privileged` into the output data structure. At /// this point, the setuid has been demoted. @@ -63,10 +63,10 @@ pub trait IsTool { /// when it falls out of scope, so `parse` (and serialization) never run as /// root. The root window is exactly the one `run_privileged` call. Serializes /// the parsed output to a `Value` so every tool returns one common type. - fn run(&self) -> Result { + fn run(&self, token: &mut Unprivileged) -> Result { let res = { - let _privileged = Privileged::acquire()?; - self.run_privileged() + let root = token.elevate()?; + self.run_privileged(&root) }; let output = self.parse(res).map_err(Error::Tool)?; @@ -116,20 +116,20 @@ impl Tool { /// op), returning its already-serialized `Value`. The binary path is taken /// from the trusted config and validated (`RootSafeBin`) here, as the real uid, /// before any privilege is acquired. - pub fn run(self) -> Result { + pub fn run(self, token: &mut Unprivileged) -> Result { let config = Config::get(); match self { Tool::Dmsetup { args } => { let bin = config.dmsetup().map_err(|e| Error::Tool(Box::new(e)))?; - Dmsetup::new(bin, args).run() + Dmsetup::new(bin, args).run(token) } - Tool::Blockdev { args } => Blockdev::new(args).run(), - Tool::Blockcopy { args } => Blockcopy::new(args).run(), - Tool::ChrootJail { op } => op.run(), - Tool::Network { op } => op.run(), + Tool::Blockdev { args } => Blockdev::new(args).run(token), + Tool::Blockcopy { args } => Blockcopy::new(args).run(token), + Tool::ChrootJail { op } => op.run(token), + Tool::Network { op } => op.run(token), Tool::ThinDump { args } => { let bin = config.thin_dump().map_err(|e| Error::Tool(Box::new(e)))?; - ThinDump::new(bin, args).run() + ThinDump::new(bin, args).run(token) } } } diff --git a/native/suidhelper/src/tools/network/exec.rs b/native/suidhelper/src/tools/network/exec.rs index 2026fc21..5543d4e0 100644 --- a/native/suidhelper/src/tools/network/exec.rs +++ b/native/suidhelper/src/tools/network/exec.rs @@ -5,9 +5,7 @@ //! the whole privileged window for every network op — `prepare`, `teardown`, //! and `host_init` differ only in which commands they hand to [`run_all`]. use super::args::{Command, Which}; -use crate::util::setuid_privileged::become_root_permanently; -use std::io; -use std::os::unix::process::CommandExt; +use crate::process::privilege::{Root, RootCommandExt}; use std::process::Command as Proc; use thiserror::Error as ThisError; @@ -30,11 +28,13 @@ pub enum Error { }, } -pub(super) fn run_all(commands: &[Command], resolve: impl Fn(Which) -> Proc) -> Result<(), Error> { +pub(super) fn run_all( + commands: &[Command], + root: &Root<'_>, + resolve: impl Fn(Which) -> Proc, +) -> Result<(), Error> { for command in commands { let mut proc = resolve(command.bin); - proc.args(&command.argv).env_clear(); - // `ip` and `nft` refuse to run under `AT_SECURE` — the setuid-like state // the kernel flags when a child is exec'd with `ruid != euid`. Our // privilege guard (`Privileged::acquire`) raises only euid to 0, leaving @@ -44,13 +44,7 @@ pub(super) fn run_all(commands: &[Command], resolve: impl Fn(Which) -> Proc) -> // same reason the jailer handoff uses `become_root_permanently`. This // runs in the forked child, so it never affects the parent's own // privilege window. - // - // SAFETY: `become_root_permanently` calls only async-signal-safe - // setresuid/setresgid/setgroups syscalls and allocates nothing, which is - // the contract `pre_exec` requires of the post-fork, pre-exec child. - unsafe { - proc.pre_exec(|| become_root_permanently().map_err(io::Error::other)); - } + proc.args(&command.argv).spawn_as_root(root); let output = proc.output().map_err(|source| Error::Spawn { bin: command.bin, diff --git a/native/suidhelper/src/tools/network/host_init.rs b/native/suidhelper/src/tools/network/host_init.rs index 5ba1e32e..d59b2a96 100644 --- a/native/suidhelper/src/tools/network/host_init.rs +++ b/native/suidhelper/src/tools/network/host_init.rs @@ -16,6 +16,7 @@ use super::exec; use super::NetworkOut; use crate::config::Config; use crate::process::binary::{self, RootSafeBin}; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::IsTool; use clap::Args; use thiserror::Error as ThisError; @@ -51,10 +52,13 @@ pub fn ip_forward_enabled(contents: &str) -> bool { contents.trim() == "1" } -pub fn run(args: HostInitArgs) -> Result { +pub fn run( + args: HostInitArgs, + token: &mut Unprivileged, +) -> Result { HostInit::new(args) .map_err(|e| crate::tools::Error::Tool(Box::new(e)))? - .run() + .run(token) } struct HostInit { @@ -86,7 +90,7 @@ impl IsTool for HostInit { type Output = NetworkOut; type RunT = Result<(), Error>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, root: &Root<'_>) -> Self::RunT { let ip_forward = std::fs::read_to_string(IP_FORWARD_PATH).map_err(Error::IpForwardRead)?; if !ip_forward_enabled(&ip_forward) { return Err(Error::IpForwardDisabled); @@ -94,7 +98,7 @@ impl IsTool for HostInit { let commands = args::host_init_commands(&self.uplink, &self.clone_pool); // host_init_commands only ever issues `nft` commands (see args.rs). - exec::run_all(&commands, |_which| self.nft.command())?; + exec::run_all(&commands, root, |_which| self.nft.command())?; Ok(()) } diff --git a/native/suidhelper/src/tools/network/mod.rs b/native/suidhelper/src/tools/network/mod.rs index e1cb94e6..1645a87c 100644 --- a/native/suidhelper/src/tools/network/mod.rs +++ b/native/suidhelper/src/tools/network/mod.rs @@ -17,6 +17,7 @@ pub mod teardown; pub mod teardown_orphan; use crate::config::Network; +use crate::process::privilege::Unprivileged; use clap::Subcommand; use serde::Serialize; use thiserror::Error as ThisError; @@ -65,12 +66,12 @@ pub enum NetworkOp { impl NetworkOp { /// Route to the selected op; each runs in its own privileged scope and /// returns its own serialized `Value`. - pub fn run(self) -> Result { + pub fn run(self, token: &mut Unprivileged) -> Result { match self { - NetworkOp::Prepare(args) => prepare::run(args), - NetworkOp::Teardown(args) => teardown::run(args), - NetworkOp::TeardownOrphan(args) => teardown_orphan::run(args), - NetworkOp::HostInit(args) => host_init::run(args), + NetworkOp::Prepare(args) => prepare::run(args, token), + NetworkOp::Teardown(args) => teardown::run(args, token), + NetworkOp::TeardownOrphan(args) => teardown_orphan::run(args, token), + NetworkOp::HostInit(args) => host_init::run(args, token), } } } diff --git a/native/suidhelper/src/tools/network/prepare.rs b/native/suidhelper/src/tools/network/prepare.rs index 0a731d71..47dabca5 100644 --- a/native/suidhelper/src/tools/network/prepare.rs +++ b/native/suidhelper/src/tools/network/prepare.rs @@ -6,6 +6,7 @@ use super::exec; use super::NetworkOut; use crate::config::Config; use crate::process::binary::{self, RootSafeBin}; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::jailer::{self, VmId}; use crate::tools::IsTool; use clap::Args; @@ -99,10 +100,13 @@ pub(super) fn resolve( Ok((plan, config.ip()?, config.nft()?, config.sysctl()?)) } -pub fn run(args: PrepareArgs) -> Result { +pub fn run( + args: PrepareArgs, + token: &mut Unprivileged, +) -> Result { Prepare::new(args) .map_err(|e| crate::tools::Error::Tool(Box::new(e)))? - .run() + .run(token) } struct Prepare { @@ -129,13 +133,13 @@ impl IsTool for Prepare { type Output = NetworkOut; type RunT = Result<(), Error>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, root: &Root<'_>) -> Self::RunT { let commands = args::prepare_commands( &self.plan, &self.nft.fd_path().to_string_lossy(), &self.sysctl.fd_path().to_string_lossy(), ); - exec::run_all(&commands, |which| match which { + exec::run_all(&commands, root, |which| match which { args::Which::Ip => self.ip.command(), args::Which::Nft => self.nft.command(), })?; diff --git a/native/suidhelper/src/tools/network/teardown.rs b/native/suidhelper/src/tools/network/teardown.rs index eb7d61fc..d65ccc71 100644 --- a/native/suidhelper/src/tools/network/teardown.rs +++ b/native/suidhelper/src/tools/network/teardown.rs @@ -5,6 +5,7 @@ use super::exec; use super::prepare; use super::NetworkOut; use crate::process::binary::RootSafeBin; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::jailer::VmId; use crate::tools::IsTool; use clap::Args; @@ -28,10 +29,13 @@ pub enum Error { Exec(#[from] exec::Error), } -pub fn run(args: TeardownArgs) -> Result { +pub fn run( + args: TeardownArgs, + token: &mut Unprivileged, +) -> Result { Teardown::new(args) .map_err(|e| crate::tools::Error::Tool(Box::new(e)))? - .run() + .run(token) } struct Teardown { @@ -54,9 +58,9 @@ impl IsTool for Teardown { type Output = NetworkOut; type RunT = Result<(), Error>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, root: &Root<'_>) -> Self::RunT { let commands = args::teardown_commands(&self.plan); - exec::run_all(&commands, |which| match which { + exec::run_all(&commands, root, |which| match which { args::Which::Ip => self.ip.command(), args::Which::Nft => self.nft.command(), })?; diff --git a/native/suidhelper/src/tools/network/teardown_orphan.rs b/native/suidhelper/src/tools/network/teardown_orphan.rs index 4a6e5b50..7adb2c70 100644 --- a/native/suidhelper/src/tools/network/teardown_orphan.rs +++ b/native/suidhelper/src/tools/network/teardown_orphan.rs @@ -19,6 +19,7 @@ use super::args::{self, Command}; use super::NetworkOut; use crate::config::Config; use crate::process::binary::{self, RootSafeBin}; +use crate::process::privilege::{Root, Unprivileged}; use crate::tools::jailer::VmId; use crate::tools::IsTool; use clap::Args; @@ -47,10 +48,13 @@ pub enum Error { Failed { argv: Vec, stderr: String }, } -pub fn run(args: TeardownOrphanArgs) -> Result { +pub fn run( + args: TeardownOrphanArgs, + token: &mut Unprivileged, +) -> Result { TeardownOrphan::new(args) .map_err(|e| crate::tools::Error::Tool(Box::new(e)))? - .run() + .run(token) } struct TeardownOrphan { @@ -74,7 +78,7 @@ impl IsTool for TeardownOrphan { type Output = NetworkOut; type RunT = Result<(), Error>; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { run_idempotent(&args::teardown_orphan_commands(&self.netns), &self.ip) } diff --git a/native/suidhelper/src/tools/thin_dump.rs b/native/suidhelper/src/tools/thin_dump.rs index 5ecf4147..43fe11ee 100644 --- a/native/suidhelper/src/tools/thin_dump.rs +++ b/native/suidhelper/src/tools/thin_dump.rs @@ -9,6 +9,7 @@ //! changing meaning, and a grammar-level parser is immune to that drift. See //! `crate::linux::dm::thin_dump` for the document model. +use crate::process::privilege::Root; use thiserror::Error as ThisError; use super::IsTool; @@ -59,7 +60,7 @@ impl IsTool for ThinDump { type Output = ThinMappings; type RunT = Result; - fn run_privileged(&self) -> Self::RunT { + fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { let meta = self.args.meta.path()?; let device = SafeFile::::open(&meta, OpenFlags::RDONLY)?; // --metadata-snap reads the caller-reserved metadata snapshot, the diff --git a/native/suidhelper/src/util/jailer.rs b/native/suidhelper/src/util/jailer.rs index cc3980d8..61c0c831 100644 --- a/native/suidhelper/src/util/jailer.rs +++ b/native/suidhelper/src/util/jailer.rs @@ -22,12 +22,10 @@ //! relative, `..`, and NUL/whitespace inputs. use crate::process::binary::{RootSafeBin, RootSafeBinPathBuf}; -use crate::util::setuid_privileged; +use crate::process::privilege::{RootCommandExt, Unprivileged}; use bon::Builder; use derive_more::{AsRef, Display}; -use nix::errno::Errno; -use std::ffi::{CString, NulError}; -use std::os::fd::{AsFd, AsRawFd, RawFd}; +use std::ffi::{CString, NulError, OsStr}; use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::str::FromStr; @@ -53,12 +51,8 @@ pub enum Error { IdNumber { value: u32, lo: u32, hi: u32 }, #[error("too many --cgroup settings: {0} (max {MAX_CGROUPS})")] TooManyCgroups(usize), - #[error(transparent)] - Privilege(#[from] setuid_privileged::Error), #[error("argument contains an interior NUL byte")] NulArgument(#[from] NulError), - #[error("execve jailer failed: {0}")] - Exec(Errno), } /// `n != 0 && lo <= n <= hi`. uid/gid 0 is rejected unconditionally: a jailer run @@ -254,7 +248,7 @@ impl Jailer<'_> { /// this never returns (the process image is replaced); the `Ok` arm is /// therefore [`std::convert::Infallible`]. Every failure is returned as /// [`Error`] for the caller to print and exit non-zero. - pub fn invoke(self) -> Result { + pub fn invoke(self, token: Unprivileged) -> Result { if self.cgroups.len() > MAX_CGROUPS { return Err(Error::TooManyCgroups(self.cgroups.len())); } @@ -262,62 +256,14 @@ impl Jailer<'_> { // Resolve everything that can fail before any privilege is raised. let argv = self.argv()?; - // Point of no return: from here we are permanently root and must execve. - setuid_privileged::become_root_permanently()?; - close_inherited_fds(self.jailer.as_fd().as_raw_fd()); - - // Empty envp: once ruid==0 the kernel clears AT_SECURE, so a smuggled - // LD_PRELOAD/LD_LIBRARY_PATH would be honored by the dynamic loader and - // hijack the root jailer. We pass nothing and let the jailer build its own. - let empty_env: [CString; 0] = []; - let errno = self - .jailer - .exec(&argv, &empty_env) - .expect_err("execve only returns on failure"); - Err(Error::Exec(errno)) - } -} - -/// Close every inherited fd above stdio so a compromised BEAM cannot smuggle an -/// open fd into the root jailer. Keep 0/1/2: MuonTrap supervises the jailer -/// through stdio, and stderr carries our exec-failure message. `close_range(2)` -/// needs Linux 5.9+; on any failure (ENOSYS or otherwise) we fall back to -/// closing each fd individually — fail closed before handing root to the jailer. -fn close_inherited_fds(keep: RawFd) { - const FIRST: u32 = 3; - let keep = keep as u32; - let ranges = [ - (FIRST, keep.saturating_sub(1)), - (keep.saturating_add(1).max(FIRST), u32::MAX), - ]; - // Invoke `close_range(2)` as a raw syscall rather than through the - // `libc::close_range` wrapper: the `libc` crate does not define that wrapper - // for every target (it is absent on aarch64-musl), but `SYS_close_range` is - // defined on all Linux arches. `syscall` returns 0 on success, -1 on error. - // SAFETY: raw syscall with no memory operands; closing fds has no UB. - let closed = ranges.iter().filter(|(lo, hi)| lo <= hi).all(|&(lo, hi)| { - let rc = unsafe { - nix::libc::syscall( - nix::libc::SYS_close_range, - lo as nix::libc::c_uint, - hi as nix::libc::c_uint, - 0 as nix::libc::c_uint, + self.jailer + .command() + .args( + argv[1..] + .iter() + .map(|arg| OsStr::from_bytes(arg.as_bytes())), ) - }; - rc == 0 - }); - if closed { - return; - } - - // SAFETY: sysconf is a pure query of a system limit. - let max = unsafe { nix::libc::sysconf(nix::libc::_SC_OPEN_MAX) }; - let max = if max < 0 { 4096 } else { max as i32 }; - for fd in (FIRST as i32..max).filter(|&fd| fd as u32 != keep) { - // SAFETY: closing an arbitrary fd is safe; EBADF for unused fds is ignored. - unsafe { - nix::libc::close(fd); - } + .exec_as_root(token) } } diff --git a/native/suidhelper/src/util/mod.rs b/native/suidhelper/src/util/mod.rs index 8f125da4..67e3811b 100644 --- a/native/suidhelper/src/util/mod.rs +++ b/native/suidhelper/src/util/mod.rs @@ -3,4 +3,3 @@ pub mod chroot_jail; pub mod jailer; pub mod safe_dev; -pub mod setuid_privileged; diff --git a/native/suidhelper/src/util/setuid_privileged.rs b/native/suidhelper/src/util/setuid_privileged.rs deleted file mode 100644 index e334b115..00000000 --- a/native/suidhelper/src/util/setuid_privileged.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! RAII privilege guard. -//! -//! The helper is installed setuid root, so the kernel makes it euid=0 at `exec`. -//! A `.preinit_array` entry ([`drop_to_real`]) immediately drops the effective uid -//! to the caller while keeping the saved-uid (root) so we can re-raise. From then -//! on the process is unprivileged, and **the only way back to root is a -//! [`Privileged`] guard** - raising to root lives nowhere else. The guard lowers -//! privileges again when it goes out of scope, so the root window is exactly the -//! guard's lifetime. -//! -//! If privileges can't be lowered we abort rather than continue as root: neither a -//! constructor nor a Drop impl can report an error, and silently staying root -//! would defeat the point. - -use nix::unistd::{getuid, seteuid, setgroups, setresgid, setresuid, Gid, Uid}; -use thiserror::Error as ThisError; - -/// Failures of the privilege transitions. All fatal: if we can't raise we aren't -/// installed setuid root, if we can't lower we refuse to keep running, and if we -/// can't seal permanent root we refuse to hand off to the execve target. -#[derive(Debug, ThisError)] -pub enum Error { - #[error("not installed setuid root")] - NotSetuidRoot, - #[error("failed to drop privileges")] - DropPrivileges, - #[error("failed to acquire permanent root for execve handoff")] - PermanentRoot, -} - -/// `.preinit_array` runs before `.init_array` and before any shared-library -/// initializer, so this is the first userspace code the process executes - earlier -/// than a normal constructor. It drops effective privileges to the real uid, so -/// root is never held outside a `Privileged` scope. -#[cfg(target_os = "linux")] -#[used] -#[link_section = ".preinit_array"] -static PREINIT_DROP_PRIVILEGES: extern "C" fn() = drop_to_real; - -// Runs before the Rust runtime is initialized, so it must avoid std entirely: no -// allocation, no std I/O, and no `std::process::abort` - those assume runtime -// state that doesn't exist this early. `seteuid`/`getuid`/`write`/`_exit` are -// bare syscalls (async-signal-safe) and valid here. -extern "C" fn drop_to_real() { - if seteuid(getuid()).is_err() { - const MSG: &[u8] = b"hyper-suidhelper: failed to drop privileges at startup\n"; - // SAFETY: raw FFI to async-signal-safe libc calls; MSG is a static buffer. - unsafe { - let _ = nix::libc::write(2, MSG.as_ptr().cast(), MSG.len()); - nix::libc::_exit(1); - } - } -} - -fn lower() -> Result<(), Error> { - seteuid(getuid()).map_err(|_| Error::DropPrivileges) -} - -#[must_use = "privileges are dropped when the guard is dropped; bind it for the privileged scope"] -pub struct Privileged { - // Private so a guard can only be built via `acquire`. - _seal: (), -} - -impl Privileged { - /// Raise to root (euid=0) for the lifetime of the guard. This is the only - /// place the process ever becomes root; the guard lowers back on drop. - pub fn acquire() -> Result { - seteuid(Uid::from_raw(0)).map_err(|_| Error::NotSetuidRoot)?; - Ok(Self { _seal: () }) - } - - /// Verify we can promote to root and back down. Used by the `sys-test` - /// command to check the helper is correctly installed. - pub fn smoke_test() -> Result<(), Error> { - // Raises here, and the guard lowers again when dropped at end of scope. - let _guard = Self::acquire()?; - Ok(()) - } -} - -/// Re-acquire full root **permanently** for an `execve` handoff and return — -/// there is deliberately no [`Privileged`] Drop guard, because `execve` replaces -/// the entire process image: nothing of this process survives to run a destructor, -/// and the new image (the firecracker jailer) is responsible for dropping its own -/// privileges. We must hand it a *genuine* root process (all of real, effective -/// and saved uid == 0) so the jailer's own privilege-drop is the real thing. -/// -/// Order matters and each step needs the privilege the previous one preserves: -/// 1. `seteuid(0)` — regain effective root; without it the rest are EPERM. -/// 2. `setresgid(0,0,0)` — set every gid to root *before* touching uids, while -/// we still hold euid 0 (gid changes require privilege). -/// 3. `setgroups([0])` — `drop_to_real` only lowered euid; it never touched the -/// caller's supplementary groups, so the jailer would otherwise inherit them. -/// Reset to just {0} now, while still privileged. -/// 4. `setresuid(0,0,0)` LAST — this seals real+effective+saved uid to root. It -/// goes last because once the saved uid is 0 there is no escape hatch left -/// (which is the point: permanent), and because it must follow the gid/group -/// changes that needed our euid-0 to be permitted. -pub fn become_root_permanently() -> Result<(), Error> { - let root_uid = Uid::from_raw(0); - let root_gid = Gid::from_raw(0); - - seteuid(root_uid).map_err(|_| Error::NotSetuidRoot)?; - setresgid(root_gid, root_gid, root_gid).map_err(|_| Error::PermanentRoot)?; - setgroups(&[root_gid]).map_err(|_| Error::PermanentRoot)?; - setresuid(root_uid, root_uid, root_uid).map_err(|_| Error::PermanentRoot)?; - - Ok(()) -} - -impl Drop for Privileged { - fn drop(&mut self) { - if lower().is_err() { - eprintln!("hyper-suidhelper: failed to drop privileges; aborting"); - std::process::abort(); - } - } -} diff --git a/native/suidhelper/tests/e2e/fake_jailer.rs b/native/suidhelper/tests/e2e/fake_jailer.rs new file mode 100644 index 00000000..010f4cd4 --- /dev/null +++ b/native/suidhelper/tests/e2e/fake_jailer.rs @@ -0,0 +1,20 @@ +use std::fs; +use std::path::PathBuf; + +const RECORDER_EXIT: i32 = 7; + +fn main() { + let dir: PathBuf = std::env::current_exe() + .expect("own executable path") + .parent() + .expect("executable has a parent directory") + .to_path_buf(); + + let argv: Vec = std::env::args().skip(1).collect(); + fs::write(dir.join("argv.json"), serde_json::to_vec(&argv).unwrap()).unwrap(); + + let environ = fs::read("/proc/self/environ").unwrap(); + fs::write(dir.join("environ.bin"), environ).unwrap(); + + std::process::exit(RECORDER_EXIT); +} diff --git a/native/suidhelper/tests/e2e/jailer.rs b/native/suidhelper/tests/e2e/jailer.rs index e1d9ca33..2651921c 100644 --- a/native/suidhelper/tests/e2e/jailer.rs +++ b/native/suidhelper/tests/e2e/jailer.rs @@ -14,31 +14,9 @@ use support::{is_root, run}; const RECORDER_EXIT: i32 = 7; -fn cat_bin() -> &'static str { - ["/bin/cat", "/usr/bin/cat"] - .into_iter() - .find(|p| Path::new(p).exists()) - .expect("a `cat` binary for the recorder") -} - -/// Install a root-owned recorder named `jailer` that writes its argv (minus -/// argv[0]) as a JSON array to `argv_rec` and copies its `/proc/self/environ` to -/// `env_rec`, then exits `RECORDER_EXIT`. Paths are baked into the script text -/// because the recorder runs with an empty environment and absolute `cat` so it -/// needs no `PATH`. Returns the recorder's absolute path. -fn install_recorder(dir: &Path, argv_rec: &Path, env_rec: &Path) -> PathBuf { +fn install_recorder(dir: &Path) -> PathBuf { let path = dir.join("jailer"); - let script = format!( - "#!/bin/sh\n\ - (\n printf '['\n sep=''\n for a in \"$@\"; do printf '%s\"%s\"' \"$sep\" \"$a\"; sep=','; done\n printf ']'\n) > '{argv}'\n\ - {cat} /proc/self/environ > '{env}'\n\ - exit {code}\n", - argv = argv_rec.display(), - cat = cat_bin(), - env = env_rec.display(), - code = RECORDER_EXIT, - ); - fs::write(&path, script).unwrap(); + fs::copy(env!("CARGO_BIN_EXE_fake-jailer"), &path).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); path } @@ -87,7 +65,7 @@ fn execs_jailer_with_canonical_argv_and_empty_env_as_root() { let tmp = tempfile::tempdir().unwrap(); let argv_rec = tmp.path().join("argv.json"); let env_rec = tmp.path().join("environ.bin"); - let jailer = install_recorder(tmp.path(), &argv_rec, &env_rec); + let jailer = install_recorder(tmp.path()); let firecracker = install_firecracker(tmp.path()); let cfg = write_root_config(tmp.path(), &jailer, &firecracker); @@ -176,8 +154,7 @@ fn execs_jailer_with_netns_flag_when_networking_enabled_as_root() { } let tmp = tempfile::tempdir().unwrap(); let argv_rec = tmp.path().join("argv.json"); - let env_rec = tmp.path().join("environ.bin"); - let jailer = install_recorder(tmp.path(), &argv_rec, &env_rec); + let jailer = install_recorder(tmp.path()); let firecracker = install_firecracker(tmp.path()); let cfg = write_root_config_with_network(tmp.path(), &jailer, &firecracker); @@ -224,8 +201,7 @@ fn refuses_uid_zero_without_exec_as_root() { } let tmp = tempfile::tempdir().unwrap(); let argv_rec = tmp.path().join("argv.json"); - let env_rec = tmp.path().join("environ.bin"); - let jailer = install_recorder(tmp.path(), &argv_rec, &env_rec); + let jailer = install_recorder(tmp.path()); let firecracker = install_firecracker(tmp.path()); let cfg = write_root_config(tmp.path(), &jailer, &firecracker); diff --git a/native/suidhelper/tests/process/privilege.rs b/native/suidhelper/tests/process/privilege.rs index c5bf8e35..374de292 100644 --- a/native/suidhelper/tests/process/privilege.rs +++ b/native/suidhelper/tests/process/privilege.rs @@ -3,17 +3,20 @@ //! first `take` in a process yields it and every later `take` refuses, even //! after the first token is dropped. -use hyper_suidhelper::process::privilege::Unprivileged; +use hyper_suidhelper::process::privilege::{TakeError, Unprivileged}; #[test] fn at_most_one_token_per_process() { { let first = Unprivileged::take(); - assert!(first.is_some(), "first take must yield the token"); - assert!(Unprivileged::take().is_none(), "second take while held"); + assert!(first.is_ok(), "first take must yield the token: {first:?}"); + assert!( + matches!(Unprivileged::take(), Err(TakeError::AlreadyTaken)), + "second take while held" + ); } assert!( - Unprivileged::take().is_none(), + matches!(Unprivileged::take(), Err(TakeError::AlreadyTaken)), "take after the first is dropped" ); } From 78274bc2d43c0d27dbda4655e9d3513f9bd1b3d5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 23:46:25 -0700 Subject: [PATCH 12/17] more cleanup --- native/suidhelper/Cargo.toml | 4 -- native/suidhelper/src/fs/path.rs | 6 ++ native/suidhelper/src/process/binary.rs | 5 +- native/suidhelper/src/tools/blockcopy.rs | 17 +++--- native/suidhelper/src/tools/blockdev.rs | 11 ++-- .../src/tools/chroot_jail/grant_api.rs | 2 +- .../src/tools/chroot_jail/grant_vsock.rs | 2 +- .../src/tools/chroot_jail/prepare.rs | 7 ++- native/suidhelper/src/tools/dmsetup/mod.rs | 2 +- native/suidhelper/src/tools/dmsetup/name.rs | 10 ++++ native/suidhelper/src/tools/dmsetup/table.rs | 17 ++---- native/suidhelper/src/tools/mod.rs | 4 +- native/suidhelper/src/util/chroot_jail.rs | 13 ++-- native/suidhelper/src/util/mod.rs | 1 - native/suidhelper/src/util/safe_dev.rs | 60 ------------------- native/suidhelper/tests/util/chroot_jail.rs | 39 +++++++++--- native/suidhelper/tests/util/safe_dev.rs | 58 ------------------ native/suidhelper/tests/util/safe_path.rs | 33 +++++++++- 18 files changed, 119 insertions(+), 172 deletions(-) delete mode 100644 native/suidhelper/src/util/safe_dev.rs delete mode 100644 native/suidhelper/tests/util/safe_dev.rs diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index ed8a2388..594127e5 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -33,10 +33,6 @@ required-features = ["insecure_test_seams"] name = "safe_path" path = "tests/util/safe_path.rs" -[[test]] -name = "safe_dev" -path = "tests/util/safe_dev.rs" - [[test]] name = "security_gate" path = "tests/security_gate.rs" diff --git a/native/suidhelper/src/fs/path.rs b/native/suidhelper/src/fs/path.rs index a73cd087..d4e96364 100644 --- a/native/suidhelper/src/fs/path.rs +++ b/native/suidhelper/src/fs/path.rs @@ -285,6 +285,12 @@ impl SafePath { StrictComponent::new_unchecked(name), )) } + + pub fn file_name(&self) -> Option<&StrictComponent> { + let raw = self.path.as_os_str().as_bytes(); + let name = raw.rsplit(|&b| b == b'/').next()?; + (!name.is_empty()).then(|| StrictComponent::new_unchecked(name)) + } } impl SafePath { diff --git a/native/suidhelper/src/process/binary.rs b/native/suidhelper/src/process/binary.rs index d9cd81fc..b77ed358 100644 --- a/native/suidhelper/src/process/binary.rs +++ b/native/suidhelper/src/process/binary.rs @@ -1,9 +1,8 @@ //! Utilities for interacting with binaries. use crate::fs::file::{self, IsRegularFile, OpenFlags, OwnerOnlyWritable, RootOwner, SafeFile}; -use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponent, StrictComponents}; use nix::errno::Errno; use nix::fcntl::{fcntl, FcntlArg, FdFlag}; -use std::ffi::OsStr; use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; @@ -52,7 +51,7 @@ impl RootSafeBin { source, })?; - if bin.file_name() != Some(OsStr::new(NAME)) { + if safe.file_name() != Some(const { StrictComponent::from_static(NAME) }) { return Err(Error::Name { expected: NAME, got: bin.to_path_buf(), diff --git a/native/suidhelper/src/tools/blockcopy.rs b/native/suidhelper/src/tools/blockcopy.rs index 0042fcd6..c49928a5 100644 --- a/native/suidhelper/src/tools/blockcopy.rs +++ b/native/suidhelper/src/tools/blockcopy.rs @@ -6,8 +6,9 @@ //! ranges are trusted as the exact divergence. use super::IsTool; +use crate::linux::dm::DmName; use crate::process::privilege::Root; -use crate::util::safe_dev::BlockDev; +use crate::tools::parse_dm_device; use clap::Args; use derive_more::Constructor; use serde::{Deserialize, Serialize}; @@ -37,11 +38,11 @@ fn range_spec_from_file(path: &str) -> Result { #[derive(Args)] pub struct BlockcopyArgs { /// Device to copy from (the fork's point-in-time snapshot). - #[arg(long)] - src: BlockDev, + #[arg(long, value_parser = parse_dm_device)] + src: DmName, /// Device to copy into (a writable dm-snapshot over the origin). - #[arg(long)] - dst: BlockDev, + #[arg(long, value_parser = parse_dm_device)] + dst: DmName, /// Copy only these block ranges (JSON file from `thin-dump`). #[arg(long, value_parser = range_spec_from_file)] ranges: RangeSpec, @@ -126,8 +127,10 @@ impl IsTool for Blockcopy { type RunT = io::Result; fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { - let mut src = File::open(&self.args.src)?; - let mut dst = OpenOptions::new().write(true).open(&self.args.dst)?; + let mut src = File::open(self.args.src.path().as_path())?; + let mut dst = OpenOptions::new() + .write(true) + .open(self.args.dst.path().as_path())?; let chunk = self.args.chunk_bytes as usize; copy_ranges(&mut src, &mut dst, &self.args.ranges, chunk) diff --git a/native/suidhelper/src/tools/blockdev.rs b/native/suidhelper/src/tools/blockdev.rs index 1715d9c9..d953cb8e 100644 --- a/native/suidhelper/src/tools/blockdev.rs +++ b/native/suidhelper/src/tools/blockdev.rs @@ -1,8 +1,9 @@ use super::IsTool; use crate::fs::file::{self, Any, IsBlockDevice, OpenFlags, SafeFile}; use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponents}; +use crate::linux::dm::DmName; use crate::process::privilege::Root; -use crate::util::safe_dev::BlockDev; +use crate::tools::parse_dm_device; use clap::Args; use serde::Serialize; use std::io; @@ -30,7 +31,8 @@ pub struct BlockdevArgs { /// Only the size query is permitted, so clap requires it. #[arg(long = "getsz", required = true)] getsz: bool, - path: BlockDev, + #[arg(value_parser = parse_dm_device)] + path: DmName, } #[derive(Serialize)] @@ -39,7 +41,7 @@ pub struct BlockdevOut { } pub struct Blockdev { - path: BlockDev, + path: DmName, } impl Blockdev { @@ -56,7 +58,8 @@ impl IsTool for Blockdev { type RunT = Result; fn run_privileged(&self, _root: &Root<'_>) -> Self::RunT { - let dev: &std::path::Path = self.path.as_ref(); + let dev = self.path.path(); + let dev = dev.as_path(); let real = std::fs::canonicalize(dev).map_err(|source| Error::Resolve { path: dev.to_path_buf(), source, diff --git a/native/suidhelper/src/tools/chroot_jail/grant_api.rs b/native/suidhelper/src/tools/chroot_jail/grant_api.rs index 82e68cfb..b3405198 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant_api.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant_api.rs @@ -80,7 +80,7 @@ impl FromStr for ApiSocket { fn from_str(s: &str) -> Result { let path: SafePathBuf = PathBuf::from(s).try_into().map_err(Error::SocketPath)?; - if path.split_last().map(|(_, leaf)| leaf) != Some(SOCKET_NAME) { + if path.file_name() != Some(SOCKET_NAME) { return Err(Error::SocketName(PathBuf::from(s))); } Ok(Self(path)) diff --git a/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs b/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs index 8a7c6843..04cfe91d 100644 --- a/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs +++ b/native/suidhelper/src/tools/chroot_jail/grant_vsock.rs @@ -65,7 +65,7 @@ impl FromStr for VsockSocket { fn from_str(s: &str) -> Result { let path: SafePathBuf = PathBuf::from(s).try_into().map_err(Error::SocketPath)?; - if path.split_last().map(|(_, leaf)| leaf) != Some(VSOCK_NAME) { + if path.file_name() != Some(VSOCK_NAME) { return Err(Error::SocketName(PathBuf::from(s))); } Ok(Self(path)) diff --git a/native/suidhelper/src/tools/chroot_jail/prepare.rs b/native/suidhelper/src/tools/chroot_jail/prepare.rs index e3c67148..b9ce3b1a 100644 --- a/native/suidhelper/src/tools/chroot_jail/prepare.rs +++ b/native/suidhelper/src/tools/chroot_jail/prepare.rs @@ -2,10 +2,11 @@ //! `chroot-jail prepare`: stage the kernel file and create the rootfs device node //! inside a VM chroot, via the [`ChrootJail`] builder. +use crate::linux::dm::DmName; use crate::process::privilege::{Root, Unprivileged}; +use crate::tools::parse_dm_device; use crate::tools::IsTool; use crate::util::chroot_jail::{self, ChrootJail}; -use crate::util::safe_dev::BlockDev; use clap::Args; use serde::Serialize; use std::path::PathBuf; @@ -20,8 +21,8 @@ pub struct PrepareArgs { kernel: PathBuf, /// Host block device to mirror as /rootfs (e.g. /dev/loop0 or /// /dev/mapper/hyper-vm1). Its major:minor are read by the helper. - #[arg(long)] - device: BlockDev, + #[arg(long, value_parser = parse_dm_device)] + device: DmName, /// UID to own the staged files. #[arg(long)] uid: u32, diff --git a/native/suidhelper/src/tools/dmsetup/mod.rs b/native/suidhelper/src/tools/dmsetup/mod.rs index e2bbd058..c378045b 100644 --- a/native/suidhelper/src/tools/dmsetup/mod.rs +++ b/native/suidhelper/src/tools/dmsetup/mod.rs @@ -12,7 +12,7 @@ use crate::process::privilege::Root; pub use backing::{BackingError, BackingFile}; use clap::{Args, Subcommand}; pub use message::{parse_message, MessageError, ThinMessage}; -pub use name::{parse_dm_name, NameError}; +pub use name::{parse_dm_device, parse_dm_name, NameError}; use serde::Serialize; use std::collections::BTreeMap; use std::path::PathBuf; diff --git a/native/suidhelper/src/tools/dmsetup/name.rs b/native/suidhelper/src/tools/dmsetup/name.rs index a7a3e752..c3715348 100644 --- a/native/suidhelper/src/tools/dmsetup/name.rs +++ b/native/suidhelper/src/tools/dmsetup/name.rs @@ -2,6 +2,7 @@ use crate::linux::dm::{DmName, InvalidDmName}; use thiserror::Error as ThisError; const HYPER_PREFIX: &str = "hyper-"; +const MAPPER_PREFIX: &str = "/dev/mapper/"; #[derive(Debug, ThisError)] pub enum NameError { @@ -9,6 +10,8 @@ pub enum NameError { Invalid(#[from] InvalidDmName), #[error("dm device name must start with `{HYPER_PREFIX}`: {0}")] NotHyper(DmName), + #[error("expected a `{MAPPER_PREFIX}` device: {0:?}")] + NotMapper(String), } pub fn parse_dm_name(s: &str) -> Result { @@ -19,3 +22,10 @@ pub fn parse_dm_name(s: &str) -> Result { Err(NameError::NotHyper(name)) } } + +pub fn parse_dm_device(s: &str) -> Result { + let name = s + .strip_prefix(MAPPER_PREFIX) + .ok_or_else(|| NameError::NotMapper(s.to_string()))?; + parse_dm_name(name) +} diff --git a/native/suidhelper/src/tools/dmsetup/table.rs b/native/suidhelper/src/tools/dmsetup/table.rs index 028f2983..06fc624a 100644 --- a/native/suidhelper/src/tools/dmsetup/table.rs +++ b/native/suidhelper/src/tools/dmsetup/table.rs @@ -1,5 +1,5 @@ use super::backing::{BackingError, BackingFile}; -use super::name::{parse_dm_name, NameError}; +use super::name::{parse_dm_device, NameError}; use crate::config::Config; use crate::fs::path::{self, IsAbsolute, SafePath, SafePathBuf, StrictComponents}; use crate::linux::dm::{ @@ -7,7 +7,6 @@ use crate::linux::dm::{ ThinPoolTable, ThinTable, }; use crate::linux::loopdev::LoopDevice; -use crate::util::safe_dev::{self, BlockDev}; use serde::Serialize; use std::fmt; use std::path::PathBuf; @@ -20,8 +19,6 @@ pub enum TableError { #[error("unsupported dm table: {0}")] Malformed(String), #[error(transparent)] - Device(#[from] safe_dev::Error), - #[error(transparent)] Name(#[from] NameError), #[error("expected a backing file, got a device: {0}")] NotAFile(String), @@ -44,7 +41,7 @@ pub enum Slot { #[derive(Debug, Clone)] pub enum Source { - Device(BlockDev), + Device(DmName), File(F), } @@ -162,7 +159,7 @@ impl SlotPath for LoopDevice { impl Source { fn path(&self) -> FilePath { match self { - Source::Device(dev) => dev.path().to_owned(), + Source::Device(dev) => dev.path(), Source::File(file) => file.slot_path().to_owned(), } } @@ -234,13 +231,9 @@ pub fn parse_table(s: &str) -> Result, TableError> { .try_into() .map_err(|e| TableError::FilePath(t.to_string(), e)) }; - let dm_device = |t: &str| -> Result { - let name = t.strip_prefix("/dev/mapper/").ok_or_else(malformed)?; - Ok(parse_dm_name(name)?) - }; let source = |t: &str| -> Result, TableError> { if is_device(t) { - Ok(Source::Device(t.parse()?)) + Ok(Source::Device(parse_dm_device(t)?)) } else { Ok(Source::File(file(t)?)) } @@ -275,7 +268,7 @@ pub fn parse_table(s: &str) -> Result, TableError> { }, ["0", sectors, "thin", pool, id, origin @ ..] if origin.len() <= 1 => TableSpec::Thin { sectors: number(sectors)?, - pool: dm_device(pool)?, + pool: parse_dm_device(pool)?, id: id.parse()?, origin: origin.first().map(|o| source(o)).transpose()?, }, diff --git a/native/suidhelper/src/tools/mod.rs b/native/suidhelper/src/tools/mod.rs index 4c0aaa15..fd8e2094 100644 --- a/native/suidhelper/src/tools/mod.rs +++ b/native/suidhelper/src/tools/mod.rs @@ -16,8 +16,8 @@ pub use blockcopy::{Blockcopy, BlockcopyArgs}; pub use blockdev::{Blockdev, BlockdevArgs}; pub use chroot_jail::ChrootJailOp; pub use dmsetup::{ - parse_dm_name, parse_message, parse_table, BackingError, BackingFile, Dmsetup, DmsetupArgs, - MessageError, NameError, Slot, Source, TableError, TableSpec, ThinMessage, + parse_dm_device, parse_dm_name, parse_message, parse_table, BackingError, BackingFile, Dmsetup, + DmsetupArgs, MessageError, NameError, Slot, Source, TableError, TableSpec, ThinMessage, }; pub use network::NetworkOp; pub use thin_dump::{ThinDump, ThinDumpArgs}; diff --git a/native/suidhelper/src/util/chroot_jail.rs b/native/suidhelper/src/util/chroot_jail.rs index eaec036c..fb7ca6fb 100644 --- a/native/suidhelper/src/util/chroot_jail.rs +++ b/native/suidhelper/src/util/chroot_jail.rs @@ -6,7 +6,7 @@ use crate::fs::dev::DeviceNumber; use crate::fs::dir::{self, SafeDir}; use crate::fs::file::{self, Any, IsBlockDevice, IsCharDevice, IsRegularFile, OpenFlags, SafeFile}; use crate::fs::path::{self, IsAbsolute, SafePath, StrictComponent, StrictComponents}; -use crate::util::safe_dev::BlockDev; +use crate::linux::dm::DmName; use nix::errno::Errno; use nix::sys::stat::{Mode, SFlag}; use nix::unistd::{Gid, Uid}; @@ -72,7 +72,7 @@ pub struct Unset; /// The kernel slot, carrying its host source path. pub struct Kernel(PathBuf); /// The rootfs slot, carrying the block device to mirror. -pub struct Rootfs(BlockDev); +pub struct Rootfs(DmName); /// A description of a VM chroot's contents. Type parameters track which slots are /// filled; see the module docs. @@ -113,7 +113,7 @@ impl ChrootJail { impl ChrootJail { /// Create the rootfs block node as `rootfs`, mirroring `device`. - pub fn with_rootfs(self, device: BlockDev) -> ChrootJail { + pub fn with_rootfs(self, device: DmName) -> ChrootJail { ChrootJail { chroot: self.chroot, uid: self.uid, @@ -203,7 +203,7 @@ pub fn stage_kernel_under( /// Create the `rootfs` block node mirroring `device`'s major:minor, owned /// `uid:gid`. The device is opened as a verified `SafeFile`, so the /// number comes from a real device node, never a caller-supplied value. -fn make_rootfs(chroot: &SafeDir, device: &BlockDev, uid: u32, gid: u32) -> Result<(), Error> { +fn make_rootfs(chroot: &SafeDir, device: &DmName, uid: u32, gid: u32) -> Result<(), Error> { // `device` is lexically validated as `/dev/loopN` or `/dev/mapper/hyper-*`. // The dm form is a symlink under `/dev/mapper` — a root-owned `0755` dir an // unprivileged caller cannot write — so resolving it to its real `/dev/dm-N` @@ -211,8 +211,9 @@ fn make_rootfs(chroot: &SafeDir, device: &BlockDev, uid: u32, gid: u32) -> Resul // refuses any symlink in the path (it must never follow an attacker-supplied // one) and would otherwise reject the dm symlink itself. Loop nodes resolve to // themselves. The re-opened target is still verified `IsBlockDevice`. - let real = std::fs::canonicalize(device.as_ref()).map_err(|source| Error::ResolveDev { - path: device.as_ref().to_path_buf(), + let device = device.path(); + let real = std::fs::canonicalize(device.as_path()).map_err(|source| Error::ResolveDev { + path: device.as_path().to_path_buf(), source, })?; let dev_path: &SafePath = real.as_path().try_into()?; diff --git a/native/suidhelper/src/util/mod.rs b/native/suidhelper/src/util/mod.rs index 67e3811b..711ecb66 100644 --- a/native/suidhelper/src/util/mod.rs +++ b/native/suidhelper/src/util/mod.rs @@ -2,4 +2,3 @@ pub mod chroot_jail; pub mod jailer; -pub mod safe_dev; diff --git a/native/suidhelper/src/util/safe_dev.rs b/native/suidhelper/src/util/safe_dev.rs deleted file mode 100644 index e8f7b431..00000000 --- a/native/suidhelper/src/util/safe_dev.rs +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: MIT -//! Validated device-name operands. -//! -//! The privileged tools must only ever touch Hyper's own devices, never -//! arbitrary system storage like `/dev/sda`. These newtypes encode that: each -//! wraps a `PathBuf`/`String` and is constructed only through its [`FromStr`] -//! impl (a textual match on the device-node name), so holding one is proof the -//! name is in-bounds. Because they parse via `FromStr`, clap validates the -//! operands at argument-parse time; borrow them as a `Path` via `AsRef`. -//! -//! Filesystem path safety (confinement, symlink-free walks, fd-relative ops) -//! lives in `crate::fs::{path, file, dir}`, not here. - -use crate::fs::path::{IsAbsolute, SafePath, SafePathBuf, StrictComponents}; -use crate::linux::loopdev::LoopDevNum; -use crate::tools::parse_dm_name; -use derive_more::{Display, Into}; -use std::path::{Path, PathBuf}; -use std::str::FromStr; -use thiserror::Error as ThisError; - -#[derive(Debug, ThisError)] -pub enum Error { - #[error("expected a loop or hyper-* dm device: {0}")] - Block(String), -} - -fn is_hyper_dm(p: &str) -> bool { - p.strip_prefix("/dev/mapper/") - .is_some_and(|name| parse_dm_name(name).is_ok()) -} - -#[derive(Debug, Clone, Display, Into)] -#[display("{}", _0.as_path().display())] -#[into(PathBuf)] -pub struct BlockDev(SafePathBuf); - -impl BlockDev { - pub fn path(&self) -> &SafePath { - &self.0 - } -} - -impl FromStr for BlockDev { - type Err = Error; - - fn from_str(s: &str) -> Result { - let invalid = || Error::Block(s.to_string()); - if s.parse::().is_err() && !is_hyper_dm(s) { - return Err(invalid()); - } - PathBuf::from(s).try_into().map(Self).map_err(|_| invalid()) - } -} - -impl AsRef for BlockDev { - fn as_ref(&self) -> &Path { - self.0.as_path() - } -} diff --git a/native/suidhelper/tests/util/chroot_jail.rs b/native/suidhelper/tests/util/chroot_jail.rs index 03980784..b803ad32 100644 --- a/native/suidhelper/tests/util/chroot_jail.rs +++ b/native/suidhelper/tests/util/chroot_jail.rs @@ -15,10 +15,10 @@ use hyper_suidhelper::fs::dir::SafeDir; use hyper_suidhelper::fs::file::OpenFlags; use hyper_suidhelper::fs::path::{IsAbsolute, RelativeError, SafePathBuf, StrictComponents}; +use hyper_suidhelper::linux::dm::DmName; use hyper_suidhelper::util::chroot_jail::{ open_chroot_under, stage_kernel_under, ChrootJail, Error, }; -use hyper_suidhelper::util::safe_dev::BlockDev; use nix::errno::Errno; use std::fs; use std::os::unix::fs::{symlink, MetadataExt}; @@ -196,6 +196,27 @@ fn teardown_loop(dev: &Path) { let _ = Command::new("losetup").arg("-d").arg(dev).output(); } +fn setup_dm(loop_dev: &Path) -> Option { + let name = format!("hyper-test-chroot-{}", std::process::id()); + let table = format!("0 2048 linear {} 0", loop_dev.display()); + let created = Command::new("dmsetup") + .args(["create", &name, "--table", &table]) + .status() + .ok()? + .success(); + if !created { + return None; + } + name.parse().ok() +} + +fn teardown_dm(name: &DmName) { + let _ = Command::new("dmsetup") + .args(["remove", "--retry"]) + .arg(name.as_os_str()) + .output(); +} + // A full build stages the kernel AND creates a `rootfs` block node mirroring the // loop device's major:minor, both owned by the requested uid:gid. #[test] @@ -216,19 +237,21 @@ fn build_under_stages_kernel_and_mirrors_device_as_root() { eprintln!("SKIP build_under: losetup unavailable"); return; }; - let block: BlockDev = dev - .to_str() - .unwrap() - .parse() - .expect("loop dev is a valid BlockDev"); + let Some(block) = setup_dm(&dev) else { + teardown_loop(&dev); + eprintln!("SKIP build_under: dmsetup unavailable"); + return; + }; + let block_path = block.path(); let (uid, gid) = (12345u32, 12345u32); let res = ChrootJail::new(chroot_dir.clone(), uid, gid) .with_kernel(kernel_src.clone()) - .with_rootfs(block) + .with_rootfs(block.clone()) .build_under(&jail_base, hyper_base); - let dev_rdev = fs::metadata(&dev).map(|m| m.rdev()); + let dev_rdev = fs::metadata(block_path.as_path()).map(|m| m.rdev()); + teardown_dm(&block); teardown_loop(&dev); res.expect("build_under must succeed as root against a real device"); diff --git a/native/suidhelper/tests/util/safe_dev.rs b/native/suidhelper/tests/util/safe_dev.rs deleted file mode 100644 index 6d1b330c..00000000 --- a/native/suidhelper/tests/util/safe_dev.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Property tests for the device/name validators — the lexical gate that keeps -//! the privileged tools off arbitrary storage and blocks path tricks. Runs -//! against the `hyper_suidhelper` library crate, not inline in source. - -use hyper_suidhelper::util::safe_dev::BlockDev; -use proptest::prelude::*; -use std::path::Path; - -fn dm_suffix() -> impl Strategy { - "[0-9A-Za-z#+.:=@_-]{0,16}" -} - -proptest! { - #[test] - fn accepts_loop_and_preserves_path(n in any::()) { - let s = format!("/dev/loop{n}"); - let dev = s.parse::().unwrap(); - prop_assert_eq!(dev.path().as_path(), Path::new(&s)); - } - - #[test] - fn accepts_hyper_dm_and_preserves_path(suffix in dm_suffix()) { - let s = format!("/dev/mapper/hyper-{suffix}"); - let dev = s.parse::().unwrap(); - prop_assert_eq!(dev.path().as_path(), Path::new(&s)); - } - - // BlockDev rejects a non-hyper dm device (e.g. `/dev/mapper/cryptroot`) — - // any mapper name not starting with `hyper-`. - #[test] - fn rejects_non_hyper_dm(name in "[a-z][a-z0-9._-]{0,12}") { - prop_assume!(!name.starts_with("hyper-")); - let s = format!("/dev/mapper/{name}"); - prop_assert!(s.parse::().is_err()); - } -} - -#[test] -fn rejects_known_attack_strings() { - for bad in [ - "/dev/loop", - "/dev/loop0/../sda", - "/dev/loopX", - "/dev/loop00", - "/dev/loop4294967296", - "/dev/sda", - "/dev/mapper/hyper-x/../../sda", - "/dev/mapper/hyper-x/", - "/dev/mapper/cryptroot", - "/dev/mapper/control", - "../dev/loop0", - ] { - assert!( - bad.parse::().is_err(), - "BlockDev accepted {bad:?}" - ); - } -} diff --git a/native/suidhelper/tests/util/safe_path.rs b/native/suidhelper/tests/util/safe_path.rs index 8efe2efa..b0b68309 100644 --- a/native/suidhelper/tests/util/safe_path.rs +++ b/native/suidhelper/tests/util/safe_path.rs @@ -2,7 +2,8 @@ //! `hyper_suidhelper` library crate (Task 6's lib split), not inline in source. use hyper_suidhelper::fs::path::{ - IsAbsolute, RelativeError, SafePathBuf, StrictComponents, ValidationError, + IsAbsolute, RelativeError, SafePath, SafePathBuf, StrictComponent, StrictComponents, + ValidationError, }; use proptest::prelude::*; use std::path::{Path, PathBuf}; @@ -117,6 +118,30 @@ proptest! { )); } + #[test] + fn file_name_returns_the_joined_component( + parts in prop::collection::vec(safe_component(), 0..5), + name in safe_component(), + ) { + let parent = Strict::try_from(join_abs(&parts)).unwrap(); + let name: &StrictComponent = name.as_str().try_into().unwrap(); + let joined = parent.join(name); + prop_assert_eq!(joined.file_name(), Some(name)); + } + + #[test] + fn file_name_of_a_relative_path_is_its_last_segment( + base_parts in prop::collection::vec(safe_component(), 1..4), + rel_parts in prop::collection::vec(safe_component(), 1..5), + ) { + let base = join_abs(&base_parts); + let full_parts: Vec = + base_parts.iter().chain(rel_parts.iter()).cloned().collect(); + let safe = Strict::try_from(join_abs(&full_parts)).unwrap(); + let rel = safe.strip_base(&base).unwrap(); + prop_assert_eq!(rel.file_name(), rel.segments().last()); + } + #[test] fn strip_base_refuses_the_base_itself( base_parts in prop::collection::vec(safe_component(), 1..4), @@ -129,3 +154,9 @@ proptest! { )); } } + +#[test] +fn root_has_no_file_name() { + let root = SafePath::::from_static::<"/">(); + assert_eq!(root.file_name(), None); +} From 2a2dd1af9dc21810a8e91bd0cd535db9c91a6df8 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 23:47:17 -0700 Subject: [PATCH 13/17] more cleanup --- native/suidhelper/tests/e2e/argv.rs | 50 +++++++++++++++-- native/suidhelper/tests/e2e/chroot_jail.rs | 53 +++++++++++++++---- .../suidhelper/tests/tools/dmsetup_parsers.rs | 42 ++++++++++++++- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/native/suidhelper/tests/e2e/argv.rs b/native/suidhelper/tests/e2e/argv.rs index d04b342c..c73875ea 100644 --- a/native/suidhelper/tests/e2e/argv.rs +++ b/native/suidhelper/tests/e2e/argv.rs @@ -70,7 +70,10 @@ fn dmsetup_create_snapshot_reconstructs_canonical_table_as_root() { .unwrap(); // Deliberately weird inner spacing; the helper must re-render canonically. - let table = format!("0 100 snapshot /dev/loop0 {} P 8", cow.display()); + let table = format!( + "0 100 snapshot /dev/mapper/hyper-img {} P 8", + cow.display() + ); let out = run( &cfg, &[ @@ -98,7 +101,7 @@ fn dmsetup_create_snapshot_reconstructs_canonical_table_as_root() { "hyper-vm1".to_string(), "--readonly".to_string(), "--table".to_string(), - format!("0 100 snapshot /dev/loop0 {cow_loop} P 8"), + format!("0 100 snapshot /dev/mapper/hyper-img {cow_loop} P 8"), ], "helper did not substitute the attached loop into the canonical table", ); @@ -231,8 +234,45 @@ impl Drop for LoopDevice { } } +struct MapperDevice { + name: String, + _loop_dev: LoopDevice, +} + +impl MapperDevice { + fn linear(loop_dev: LoopDevice, sectors: u64) -> Self { + let name = format!("hyper-e2e-getsz-{}", std::process::id()); + let table = format!("0 {sectors} linear {} 0", loop_dev.0); + let out = std::process::Command::new("dmsetup") + .args(["create", &name, "--table", &table]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + Self { + name, + _loop_dev: loop_dev, + } + } + + fn path(&self) -> String { + format!("/dev/mapper/{}", self.name) + } +} + +impl Drop for MapperDevice { + fn drop(&mut self) { + let _ = std::process::Command::new("dmsetup") + .args(["remove", "--retry", &self.name]) + .status(); + } +} + #[test] -fn blockdev_getsz_reports_a_loop_devices_size_as_root() { +fn blockdev_getsz_reports_a_dm_devices_size_as_root() { if !is_root() { eprintln!("SKIP blockdev: needs root"); return; @@ -243,10 +283,10 @@ fn blockdev_getsz_reports_a_loop_devices_size_as_root() { .unwrap() .set_len(3 * 1024 * 1024 + 512) .unwrap(); - let dev = LoopDevice::attach(&backing); + let dev = MapperDevice::linear(LoopDevice::attach(&backing), 6145); let cfg = write_root_config(tmp.path(), &[]); - let out = run(&cfg, &["blockdev", "--getsz", &dev.0]).success(); + let out = run(&cfg, &["blockdev", "--getsz", &dev.path()]).success(); let json = stdout_json(&out); assert_eq!(json["sectors"], 6145); } diff --git a/native/suidhelper/tests/e2e/chroot_jail.rs b/native/suidhelper/tests/e2e/chroot_jail.rs index be2df643..c3a617a8 100644 --- a/native/suidhelper/tests/e2e/chroot_jail.rs +++ b/native/suidhelper/tests/e2e/chroot_jail.rs @@ -67,6 +67,41 @@ fn teardown_loop(dev: &Path) { let _ = Command::new("losetup").arg("-d").arg(dev).output(); } +struct MapperDev { + loop_dev: PathBuf, + name: String, +} + +impl MapperDev { + fn setup(tmp: &Path, test: &str) -> Option { + let loop_dev = setup_loop(tmp)?; + let name = format!("hyper-e2e-{test}-{}", std::process::id()); + let table = format!("0 2048 linear {} 0", loop_dev.display()); + let created = Command::new("dmsetup") + .args(["create", &name, "--table", &table]) + .status() + .is_ok_and(|status| status.success()); + if !created { + teardown_loop(&loop_dev); + return None; + } + Some(Self { loop_dev, name }) + } + + fn path(&self) -> String { + format!("/dev/mapper/{}", self.name) + } +} + +impl Drop for MapperDev { + fn drop(&mut self) { + let _ = Command::new("dmsetup") + .args(["remove", "--retry", &self.name]) + .output(); + teardown_loop(&self.loop_dev); + } +} + // `chroot-jail prepare` stages the kernel and creates the rootfs node, exiting 0 // with {"result":"prepared"}. #[test] @@ -83,8 +118,8 @@ fn prepare_succeeds_and_builds_jail_as_root() { fs::write(&kernel, b"kernel image").unwrap(); let cfg = write_config(tmp.path(), &work); - let Some(dev) = setup_loop(tmp.path()) else { - eprintln!("SKIP prepare: losetup unavailable"); + let Some(dev) = MapperDev::setup(tmp.path(), "prepare") else { + eprintln!("SKIP prepare: losetup/dmsetup unavailable"); return; }; @@ -98,15 +133,15 @@ fn prepare_succeeds_and_builds_jail_as_root() { "--kernel", kernel.to_str().unwrap(), "--device", - dev.to_str().unwrap(), + &dev.path(), "--uid", "0", "--gid", "0", ], ); - let dev_rdev = fs::metadata(&dev).map(|m| m.rdev()); - teardown_loop(&dev); + let dev_rdev = fs::metadata(dev.path()).map(|m| m.rdev()); + drop(dev); let json = stdout_json(&out.success()); assert_eq!(json["result"], "prepared"); @@ -133,8 +168,8 @@ fn prepare_creates_tun_node_when_networking_enabled_as_root() { fs::write(&kernel, b"kernel image").unwrap(); let cfg = write_config_with_network(tmp.path(), &work); - let Some(dev) = setup_loop(tmp.path()) else { - eprintln!("SKIP prepare_creates_tun_node: losetup unavailable"); + let Some(dev) = MapperDev::setup(tmp.path(), "tun") else { + eprintln!("SKIP prepare_creates_tun_node: losetup/dmsetup unavailable"); return; }; @@ -150,14 +185,14 @@ fn prepare_creates_tun_node_when_networking_enabled_as_root() { "--kernel", kernel.to_str().unwrap(), "--device", - dev.to_str().unwrap(), + &dev.path(), "--uid", &uid.to_string(), "--gid", &gid.to_string(), ], ); - teardown_loop(&dev); + drop(dev); out.success(); let tun = chroot.join("dev").join("net").join("tun"); diff --git a/native/suidhelper/tests/tools/dmsetup_parsers.rs b/native/suidhelper/tests/tools/dmsetup_parsers.rs index 67bb53cd..833a2aad 100644 --- a/native/suidhelper/tests/tools/dmsetup_parsers.rs +++ b/native/suidhelper/tests/tools/dmsetup_parsers.rs @@ -6,8 +6,11 @@ //! arity. These are the contract that keeps dmsetup off arbitrary storage. use hyper_suidhelper::linux::dm::ThinId; -use hyper_suidhelper::tools::{parse_dm_name, parse_message, parse_table, NameError, ThinMessage}; +use hyper_suidhelper::tools::{ + parse_dm_device, parse_dm_name, parse_message, parse_table, NameError, ThinMessage, +}; use proptest::prelude::*; +use std::path::Path; fn id(n: u32) -> ThinId { ThinId::new(n).unwrap() @@ -87,6 +90,20 @@ proptest! { prop_assert!(matches!(parse_dm_name(&s), Err(NameError::Invalid(_)))); } + #[test] + fn dm_device_accepts_hyper_mapper_paths_and_preserves_them(suffix in "[0-9A-Za-z#+.:=@_-]{0,32}") { + let s = format!("/dev/mapper/hyper-{suffix}"); + let path = parse_dm_device(&s).unwrap().path(); + prop_assert_eq!(path.as_path(), Path::new(&s)); + } + + #[test] + fn dm_device_refuses_mapper_names_we_do_not_own(name in "[a-z][a-z0-9._-]{0,12}") { + prop_assume!(!name.starts_with("hyper-") && name != "control"); + let s = format!("/dev/mapper/{name}"); + prop_assert!(matches!(parse_dm_device(&s), Err(NameError::NotHyper(_)))); + } + #[test] fn a_device_is_never_accepted_as_a_backing_file(rest in "[A-Za-z0-9._/-]{1,32}") { let dev = format!("/dev/{rest}"); @@ -165,3 +182,26 @@ fn thinmessage_rejects_non_whitelisted() { ); } } + +#[test] +fn dm_device_rejects_known_attack_strings() { + for bad in [ + "/dev/sda", + "/dev/loop0", + "/dev/loop0/../sda", + "/dev/mapper/hyper-x/../../sda", + "/dev/mapper/hyper-x/", + "/dev/mapper/cryptroot", + "/dev/mapper/control", + "/dev/mapper/", + "/dev/mapper", + "../dev/mapper/hyper-x", + "dev/mapper/hyper-x", + "hyper-x", + ] { + assert!( + parse_dm_device(bad).is_err(), + "parse_dm_device accepted {bad:?}" + ); + } +} From 610ed4bc78027b00728fcf714884a6b606995f34 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 28 Oct 2026 23:54:27 -0700 Subject: [PATCH 14/17] chore: some cleanup --- AGENTS.md | 3 + native/suidhelper/Cargo.toml | 8 - native/suidhelper/src/linux/dm/name.rs | 59 +++++++ native/suidhelper/src/linux/dm/thin.rs | 169 ++++++++++++++++++ native/suidhelper/src/linux/loopdev.rs | 60 +++++++ native/suidhelper/tests/linux/dm.rs | 215 ----------------------- native/suidhelper/tests/linux/loopdev.rs | 56 ------ 7 files changed, 291 insertions(+), 279 deletions(-) delete mode 100644 native/suidhelper/tests/linux/dm.rs delete mode 100644 native/suidhelper/tests/linux/loopdev.rs diff --git a/AGENTS.md b/AGENTS.md index eab8667e..69bfc5b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,9 @@ If a case needs its own assertions, it is a separate test, not a row. - **Tests live in `tests/`, never inline `#[cfg(test)]` in `src/`.** The crate is split into a lib (`src/lib.rs`, crate `hyper_suidhelper`) and a thin bin (`src/main.rs`) precisely so integration tests can reach the internals. + **Exception: `src/linux/`.** Tests for these utilities are colocated with + the code, in a `#[cfg(test)] mod tests` block at the bottom of the file + under test. - Each test file must be registered as a `[[test]]` target in `Cargo.toml` (`tests/` subdirectories are not auto-discovered) — copy an existing `[[test]]` block when adding one. diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 594127e5..81f0040f 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -53,14 +53,6 @@ path = "tests/process/privilege.rs" name = "tools_dmsetup_parsers" path = "tests/tools/dmsetup_parsers.rs" -[[test]] -name = "linux_dm" -path = "tests/linux/dm.rs" - -[[test]] -name = "linux_loopdev" -path = "tests/linux/loopdev.rs" - [[test]] name = "tools_blockcopy" path = "tests/tools/blockcopy.rs" diff --git a/native/suidhelper/src/linux/dm/name.rs b/native/suidhelper/src/linux/dm/name.rs index c3fe51f4..772f6852 100644 --- a/native/suidhelper/src/linux/dm/name.rs +++ b/native/suidhelper/src/linux/dm/name.rs @@ -54,3 +54,62 @@ impl AsRef for DmName { self.as_os_str() } } + +#[cfg(test)] +mod tests { + use super::DmName; + use proptest::prelude::*; + use std::path::Path; + + const DM_NAME_CHARS: &str = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#+-.:=@_"; + + fn dm_name() -> impl Strategy { + "[0-9A-Za-z#+.:=@_][0-9A-Za-z#+.:=@_-]{0,126}".prop_filter("reserved", |s| { + !matches!(s.as_str(), "." | ".." | "control") + }) + } + + proptest! { + #[test] + fn round_trips_and_lives_under_dev_mapper(s in dm_name()) { + let dm: DmName = s.parse().unwrap(); + prop_assert_eq!(dm.to_string(), s.clone()); + let expected = format!("/dev/mapper/{s}"); + let path = dm.path(); + prop_assert_eq!(path.as_path(), Path::new(&expected)); + } + + #[test] + fn rejects_any_byte_outside_the_charset( + s in dm_name(), + at in any::(), + bad in any::().prop_filter("charset", |c| !DM_NAME_CHARS.contains(*c)), + ) { + let mut chars: Vec = s.chars().collect(); + chars.insert(at.index(chars.len() + 1), bad); + let s: String = chars.into_iter().collect(); + prop_assert!(s.parse::().is_err(), "accepted {:?}", s); + } + + #[test] + fn rejects_names_past_127_bytes(s in "[0-9A-Za-z]{128,200}") { + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_a_leading_dash(s in dm_name()) { + let dashed = format!("-{s}"); + prop_assert!(dashed.parse::().is_err()); + } + } + + #[test] + fn rejects_reserved_and_edge_names() { + for bad in [ + "", ".", "..", "control", "-", "--help", "a/b", "/x", "a\0b", "a b", + ] { + assert!(bad.parse::().is_err(), "accepted {bad:?}"); + } + } +} diff --git a/native/suidhelper/src/linux/dm/thin.rs b/native/suidhelper/src/linux/dm/thin.rs index 3e3c0dbb..6545f525 100644 --- a/native/suidhelper/src/linux/dm/thin.rs +++ b/native/suidhelper/src/linux/dm/thin.rs @@ -198,3 +198,172 @@ impl<'a> Thin<'a, Active<'a>> { }) } } + +#[cfg(test)] +mod tests { + use super::{Inactive, SnapshotError, Thin, ThinId, ThinPoolError}; + use crate::linux::dm::{DmName, Dmsetup}; + use crate::process::binary::RootSafeBin; + use proptest::prelude::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use tempfile::TempDir; + + proptest! { + #[test] + fn thin_id_accepts_exactly_24_bit_values( + n in prop_oneof![0..=ThinId::MAX, (ThinId::MAX + 1)..=u32::MAX] + ) { + match ThinId::new(n) { + Ok(id) => { + prop_assert!(n <= ThinId::MAX); + prop_assert_eq!(id.to_string(), n.to_string()); + prop_assert_eq!(n.to_string().parse::().unwrap(), id); + } + Err(_) => { + prop_assert!(n > ThinId::MAX); + prop_assert!(n.to_string().parse::().is_err()); + } + } + } + + #[test] + fn thin_id_parse_rejects_non_integers(s in "[^0-9+]*|[0-9]+[^0-9]+[0-9]*") { + prop_assert!(s.parse::().is_err()); + } + } + + const POOL_TABLE: &str = "0 100 thin-pool 7:0 7:1 128 0 0"; + + fn name(s: &str) -> DmName { + s.parse().unwrap() + } + + fn is_root() -> bool { + nix::unistd::geteuid().is_root() + } + + struct Fake { + _dir: TempDir, + log: PathBuf, + dmsetup: Dmsetup, + } + + impl Fake { + fn new(table: &str, failing_message: &str) -> Self { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("calls"); + let bin = dir.path().join("dmsetup"); + let script = format!( + "#!/bin/sh\n\ + printf '%s\\n' \"$*\" >> '{log}'\n\ + case \"$1\" in\n\ + table) printf '%s\\n' '{table}' ;;\n\ + message) [ \"$4\" = '{failing_message}' ] && exit 1 ;;\n\ + esac\n\ + exit 0\n", + log = log.display(), + ); + fs::write(&bin, script).unwrap(); + fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap(); + let dmsetup = Dmsetup::new(RootSafeBin::open(&bin).unwrap()); + Self { + _dir: dir, + log, + dmsetup, + } + } + + fn calls(&self) -> Vec { + fs::read_to_string(&self.log) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() + } + } + + #[test] + fn thin_pool_refuses_a_device_that_is_not_a_pool_as_root() { + if !is_root() { + eprintln!("SKIP thin_pool_refuses: needs root to own the fake dmsetup"); + return; + } + for table in [ + "0 100 linear 7:0 0", + "0 100 thin /dev/mapper/hyper-pool 1", + "", + ] { + let fake = Fake::new(table, ""); + let got = fake.dmsetup.thin_pool(&name("hyper-x")); + assert!( + matches!(got, Err(ThinPoolError::NotAThinPool { .. })), + "{table:?}: {got:?}" + ); + assert_eq!(fake.calls(), ["table hyper-x"], "{table:?}"); + } + } + + #[test] + fn snapshot_always_resumes_the_origin_as_root() { + if !is_root() { + eprintln!("SKIP snapshot_always_resumes: needs root to own the fake dmsetup"); + return; + } + let ok: fn(&Result, SnapshotError>) -> bool = |r| { + r.as_ref() + .is_ok_and(|snap| snap.id() == ThinId::new(2).unwrap()) + }; + let create_snap_failed: fn(&Result, SnapshotError>) -> bool = + |r| matches!(r, Err(SnapshotError::CreateSnap(_))); + for (failing_message, expected) in [("", ok), ("create_snap 2 1", create_snap_failed)] { + let fake = Fake::new(POOL_TABLE, failing_message); + let pool = fake.dmsetup.thin_pool(&name("hyper-pool")).unwrap(); + let active = pool + .create_thin(ThinId::new(1).unwrap()) + .unwrap() + .activate(&name("hyper-vol"), 100, None) + .unwrap(); + + let got = active.snapshot(ThinId::new(2).unwrap()); + + assert!(expected(&got), "{failing_message:?}: {got:?}"); + assert_eq!( + fake.calls(), + [ + "table hyper-pool", + "message hyper-pool 0 create_thin 1", + "create hyper-vol --table 0 100 thin /dev/mapper/hyper-pool 1", + "suspend hyper-vol", + "message hyper-pool 0 create_snap 2 1", + "resume hyper-vol", + ], + "{failing_message:?}" + ); + } + } + + #[test] + fn deactivate_then_delete_removes_the_device_then_frees_its_id_as_root() { + if !is_root() { + eprintln!("SKIP delete_removes: needs root to own the fake dmsetup"); + return; + } + let fake = Fake::new(POOL_TABLE, ""); + let pool = fake.dmsetup.thin_pool(&name("hyper-pool")).unwrap(); + pool.create_thin(ThinId::new(1).unwrap()) + .unwrap() + .activate(&name("hyper-vol"), 100, None) + .unwrap() + .deactivate() + .unwrap() + .delete() + .unwrap(); + + assert_eq!( + fake.calls()[3..], + ["remove --retry hyper-vol", "message hyper-pool 0 delete 1"] + ); + } +} diff --git a/native/suidhelper/src/linux/loopdev.rs b/native/suidhelper/src/linux/loopdev.rs index 717364d9..51f8dd28 100644 --- a/native/suidhelper/src/linux/loopdev.rs +++ b/native/suidhelper/src/linux/loopdev.rs @@ -214,3 +214,63 @@ impl LoopDevice { &self.path } } + +#[cfg(test)] +mod tests { + use super::LoopDevNum; + use proptest::prelude::*; + use std::path::Path; + + proptest! { + #[test] + fn canonical_names_round_trip(n in any::()) { + let s = format!("/dev/loop{n}"); + let num: LoopDevNum = s.parse().unwrap(); + prop_assert_eq!(num.get(), n); + prop_assert_eq!(num.to_string(), s.clone()); + let path = num.path().unwrap(); + prop_assert_eq!(path.as_path(), Path::new(&s)); + } + + #[test] + fn rejects_leading_zeros(digits in "0[0-9]{1,9}") { + let s = format!("/dev/loop{digits}"); + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_values_past_u32(n in (u64::from(u32::MAX) + 1)..=u64::MAX) { + let s = format!("/dev/loop{n}"); + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_non_numeric_suffix(suffix in "([^0-9].*)?") { + let s = format!("/dev/loop{suffix}"); + prop_assert!(s.parse::().is_err()); + } + + #[test] + fn rejects_digit_then_junk(n in any::(), junk in "[^0-9].*") { + let s = format!("/dev/loop{n}{junk}"); + prop_assert!(s.parse::().is_err()); + } + } + + #[test] + fn rejects_known_attack_strings() { + for bad in [ + "/dev/loop", + "/dev/loop+7", + "/dev/loop-1", + "/dev/loop0/../sda", + "/dev/sda", + "../dev/loop0", + "dev/loop0", + "/dev//loop0", + "7", + ] { + assert!(bad.parse::().is_err(), "accepted {bad:?}"); + } + } +} diff --git a/native/suidhelper/tests/linux/dm.rs b/native/suidhelper/tests/linux/dm.rs deleted file mode 100644 index f32b166b..00000000 --- a/native/suidhelper/tests/linux/dm.rs +++ /dev/null @@ -1,215 +0,0 @@ -use hyper_suidhelper::linux::dm::ThinId; -use hyper_suidhelper::linux::dm::{DmName, Dmsetup, Inactive, SnapshotError, Thin, ThinPoolError}; -use hyper_suidhelper::process::binary::RootSafeBin; -use proptest::prelude::*; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use tempfile::TempDir; - -const DM_NAME_CHARS: &str = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#+-.:=@_"; - -fn dm_name() -> impl Strategy { - "[0-9A-Za-z#+.:=@_][0-9A-Za-z#+.:=@_-]{0,126}".prop_filter("reserved", |s| { - !matches!(s.as_str(), "." | ".." | "control") - }) -} - -fn name(s: &str) -> DmName { - s.parse().unwrap() -} - -proptest! { - #[test] - fn dm_name_round_trips_and_lives_under_dev_mapper(s in dm_name()) { - let dm: DmName = s.parse().unwrap(); - prop_assert_eq!(dm.to_string(), s.clone()); - let expected = format!("/dev/mapper/{s}"); - let path = dm.path(); - prop_assert_eq!(path.as_path(), Path::new(&expected)); - } - - #[test] - fn dm_name_rejects_any_byte_outside_the_charset( - s in dm_name(), - at in any::(), - bad in any::().prop_filter("charset", |c| !DM_NAME_CHARS.contains(*c)), - ) { - let mut chars: Vec = s.chars().collect(); - chars.insert(at.index(chars.len() + 1), bad); - let s: String = chars.into_iter().collect(); - prop_assert!(s.parse::().is_err(), "accepted {:?}", s); - } - - #[test] - fn dm_name_rejects_names_past_127_bytes(s in "[0-9A-Za-z]{128,200}") { - prop_assert!(s.parse::().is_err()); - } - - #[test] - fn dm_name_rejects_a_leading_dash(s in dm_name()) { - let dashed = format!("-{s}"); - prop_assert!(dashed.parse::().is_err()); - } - - #[test] - fn thin_id_accepts_exactly_24_bit_values( - n in prop_oneof![0..=ThinId::MAX, (ThinId::MAX + 1)..=u32::MAX] - ) { - match ThinId::new(n) { - Ok(id) => { - prop_assert!(n <= ThinId::MAX); - prop_assert_eq!(id.to_string(), n.to_string()); - prop_assert_eq!(n.to_string().parse::().unwrap(), id); - } - Err(_) => { - prop_assert!(n > ThinId::MAX); - prop_assert!(n.to_string().parse::().is_err()); - } - } - } - - #[test] - fn thin_id_parse_rejects_non_integers(s in "[^0-9+]*|[0-9]+[^0-9]+[0-9]*") { - prop_assert!(s.parse::().is_err()); - } -} - -#[test] -fn dm_name_rejects_reserved_and_edge_names() { - for bad in [ - "", ".", "..", "control", "-", "--help", "a/b", "/x", "a\0b", "a b", - ] { - assert!(bad.parse::().is_err(), "accepted {bad:?}"); - } -} - -const POOL_TABLE: &str = "0 100 thin-pool 7:0 7:1 128 0 0"; - -fn is_root() -> bool { - nix::unistd::geteuid().is_root() -} - -struct Fake { - _dir: TempDir, - log: PathBuf, - dmsetup: Dmsetup, -} - -impl Fake { - fn new(table: &str, failing_message: &str) -> Self { - let dir = tempfile::tempdir().unwrap(); - let log = dir.path().join("calls"); - let bin = dir.path().join("dmsetup"); - let script = format!( - "#!/bin/sh\n\ - printf '%s\\n' \"$*\" >> '{log}'\n\ - case \"$1\" in\n\ - table) printf '%s\\n' '{table}' ;;\n\ - message) [ \"$4\" = '{failing_message}' ] && exit 1 ;;\n\ - esac\n\ - exit 0\n", - log = log.display(), - ); - fs::write(&bin, script).unwrap(); - fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap(); - let dmsetup = Dmsetup::new(RootSafeBin::open(&bin).unwrap()); - Self { - _dir: dir, - log, - dmsetup, - } - } - - fn calls(&self) -> Vec { - fs::read_to_string(&self.log) - .unwrap_or_default() - .lines() - .map(str::to_string) - .collect() - } -} - -#[test] -fn thin_pool_refuses_a_device_that_is_not_a_pool_as_root() { - if !is_root() { - eprintln!("SKIP thin_pool_refuses: needs root to own the fake dmsetup"); - return; - } - for table in [ - "0 100 linear 7:0 0", - "0 100 thin /dev/mapper/hyper-pool 1", - "", - ] { - let fake = Fake::new(table, ""); - let got = fake.dmsetup.thin_pool(&name("hyper-x")); - assert!( - matches!(got, Err(ThinPoolError::NotAThinPool { .. })), - "{table:?}: {got:?}" - ); - assert_eq!(fake.calls(), ["table hyper-x"], "{table:?}"); - } -} - -#[test] -fn snapshot_always_resumes_the_origin_as_root() { - if !is_root() { - eprintln!("SKIP snapshot_always_resumes: needs root to own the fake dmsetup"); - return; - } - let ok: fn(&Result, SnapshotError>) -> bool = |r| { - r.as_ref() - .is_ok_and(|snap| snap.id() == ThinId::new(2).unwrap()) - }; - let create_snap_failed: fn(&Result, SnapshotError>) -> bool = - |r| matches!(r, Err(SnapshotError::CreateSnap(_))); - for (failing_message, expected) in [("", ok), ("create_snap 2 1", create_snap_failed)] { - let fake = Fake::new(POOL_TABLE, failing_message); - let pool = fake.dmsetup.thin_pool(&name("hyper-pool")).unwrap(); - let active = pool - .create_thin(ThinId::new(1).unwrap()) - .unwrap() - .activate(&name("hyper-vol"), 100, None) - .unwrap(); - - let got = active.snapshot(ThinId::new(2).unwrap()); - - assert!(expected(&got), "{failing_message:?}: {got:?}"); - assert_eq!( - fake.calls(), - [ - "table hyper-pool", - "message hyper-pool 0 create_thin 1", - "create hyper-vol --table 0 100 thin /dev/mapper/hyper-pool 1", - "suspend hyper-vol", - "message hyper-pool 0 create_snap 2 1", - "resume hyper-vol", - ], - "{failing_message:?}" - ); - } -} - -#[test] -fn deactivate_then_delete_removes_the_device_then_frees_its_id_as_root() { - if !is_root() { - eprintln!("SKIP delete_removes: needs root to own the fake dmsetup"); - return; - } - let fake = Fake::new(POOL_TABLE, ""); - let pool = fake.dmsetup.thin_pool(&name("hyper-pool")).unwrap(); - pool.create_thin(ThinId::new(1).unwrap()) - .unwrap() - .activate(&name("hyper-vol"), 100, None) - .unwrap() - .deactivate() - .unwrap() - .delete() - .unwrap(); - - assert_eq!( - fake.calls()[3..], - ["remove --retry hyper-vol", "message hyper-pool 0 delete 1"] - ); -} diff --git a/native/suidhelper/tests/linux/loopdev.rs b/native/suidhelper/tests/linux/loopdev.rs deleted file mode 100644 index ec9a2b64..00000000 --- a/native/suidhelper/tests/linux/loopdev.rs +++ /dev/null @@ -1,56 +0,0 @@ -use hyper_suidhelper::linux::loopdev::LoopDevNum; -use proptest::prelude::*; -use std::path::Path; - -proptest! { - #[test] - fn canonical_names_round_trip(n in any::()) { - let s = format!("/dev/loop{n}"); - let num: LoopDevNum = s.parse().unwrap(); - prop_assert_eq!(num.get(), n); - prop_assert_eq!(num.to_string(), s.clone()); - let path = num.path().unwrap(); - prop_assert_eq!(path.as_path(), Path::new(&s)); - } - - #[test] - fn rejects_leading_zeros(digits in "0[0-9]{1,9}") { - let s = format!("/dev/loop{digits}"); - prop_assert!(s.parse::().is_err()); - } - - #[test] - fn rejects_values_past_u32(n in (u64::from(u32::MAX) + 1)..=u64::MAX) { - let s = format!("/dev/loop{n}"); - prop_assert!(s.parse::().is_err()); - } - - #[test] - fn rejects_non_numeric_suffix(suffix in "([^0-9].*)?") { - let s = format!("/dev/loop{suffix}"); - prop_assert!(s.parse::().is_err()); - } - - #[test] - fn rejects_digit_then_junk(n in any::(), junk in "[^0-9].*") { - let s = format!("/dev/loop{n}{junk}"); - prop_assert!(s.parse::().is_err()); - } -} - -#[test] -fn rejects_known_attack_strings() { - for bad in [ - "/dev/loop", - "/dev/loop+7", - "/dev/loop-1", - "/dev/loop0/../sda", - "/dev/sda", - "../dev/loop0", - "dev/loop0", - "/dev//loop0", - "7", - ] { - assert!(bad.parse::().is_err(), "accepted {bad:?}"); - } -} From 536325eabb879f132c6d4db2c5847a4f3b5f6dd7 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 29 Oct 2026 00:19:35 -0700 Subject: [PATCH 15/17] test: some tests for dm and cgroup --- native/suidhelper/src/linux/cgroup/events.rs | 93 ++++++++ native/suidhelper/src/linux/cgroup/mod.rs | 104 +++++++++ native/suidhelper/src/linux/dm/thin_dump.rs | 210 +++++++++++++++++++ 3 files changed, 407 insertions(+) diff --git a/native/suidhelper/src/linux/cgroup/events.rs b/native/suidhelper/src/linux/cgroup/events.rs index 2061c935..662668b9 100644 --- a/native/suidhelper/src/linux/cgroup/events.rs +++ b/native/suidhelper/src/linux/cgroup/events.rs @@ -76,3 +76,96 @@ impl Events<'_> { } } } + +#[cfg(test)] +mod tests { + use super::{Events, EventsState, ReadError}; + use crate::fs::file::{OpenFlags, SafeFile}; + use crate::fs::path::{IsAbsolute, SafePath, StrictComponents}; + use proptest::prelude::*; + use std::fs; + use tempfile::NamedTempFile; + + fn events_over(contents: &[u8]) -> (NamedTempFile, Events<'static>) { + let tmp = NamedTempFile::new().unwrap(); + fs::write(tmp.path(), contents).unwrap(); + let path: &SafePath = tmp.path().try_into().unwrap(); + let events = Events::new(SafeFile::open(path, OpenFlags::RDONLY).unwrap()); + (tmp, events) + } + + fn render(populated: bool, frozen: bool, populated_first: bool, decoys: &[String]) -> String { + let mut lines = vec![ + format!("populated {}", u8::from(populated)), + format!("frozen {}", u8::from(frozen)), + ]; + if !populated_first { + lines.reverse(); + } + for (i, decoy) in decoys.iter().enumerate() { + lines.insert(i % (lines.len() + 1), decoy.clone()); + } + lines.iter().map(|l| format!("{l}\n")).collect() + } + + fn decoy() -> impl Strategy { + prop_oneof![ + "[a-z_]{1,12} [0-9]{1,3}".prop_filter("real key", |s| { + !s.starts_with("populated ") && !s.starts_with("frozen ") + }), + "(populated|frozen)[a-z_]{1,6} [01]", + "[a-z_]{1,6}(populated|frozen) [01]", + ] + } + + proptest! { + #[test] + fn reads_exactly_the_populated_and_frozen_flags( + populated in any::(), + frozen in any::(), + populated_first in any::(), + decoys in proptest::collection::vec(decoy(), 0..4), + ) { + let (_tmp, events) = events_over(render(populated, frozen, populated_first, &decoys).as_bytes()); + prop_assert_eq!(events.read().unwrap(), EventsState { populated, frozen }); + } + + #[test] + fn a_missing_or_non_boolean_flag_is_malformed( + value in "[^\r\n]{0,32}".prop_filter("boolean", |v| v != "0" && v != "1"), + corrupt_populated in any::(), + drop_line in any::(), + ) { + let (key, other) = if corrupt_populated { + ("populated", "frozen 0") + } else { + ("frozen", "populated 0") + }; + let contents = if drop_line { + format!("{other}\n") + } else { + format!("{key} {value}\n{other}\n") + }; + let (_tmp, events) = events_over(contents.as_bytes()); + let got = events.read(); + prop_assert!(matches!(got, Err(ReadError::Malformed(_))), "{:?}: {:?}", contents, got); + } + + #[test] + fn every_read_reflects_the_current_contents( + states in proptest::collection::vec((any::(), any::()), 1..8), + ) { + let (tmp, events) = events_over(b""); + for (populated, frozen) in states { + fs::write(tmp.path(), render(populated, frozen, true, &[])).unwrap(); + prop_assert_eq!(events.read().unwrap(), EventsState { populated, frozen }); + } + } + } + + #[test] + fn non_utf8_contents_are_an_error() { + let (_tmp, events) = events_over(b"populated \xff\nfrozen 0\n"); + assert!(matches!(events.read(), Err(ReadError::Utf8(_)))); + } +} diff --git a/native/suidhelper/src/linux/cgroup/mod.rs b/native/suidhelper/src/linux/cgroup/mod.rs index 7a57099f..eaa68e8e 100644 --- a/native/suidhelper/src/linux/cgroup/mod.rs +++ b/native/suidhelper/src/linux/cgroup/mod.rs @@ -144,3 +144,107 @@ impl Cgroup { } } } + +#[cfg(test)] +mod tests { + use super::{Cgroup, OpenError, ROOT}; + use crate::fs::path::{IsAbsolute, SafePath, StrictComponents}; + use nix::errno::Errno; + use nix::libc::SIGKILL; + use std::fs; + use std::os::unix::process::ExitStatusExt; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command}; + use std::time::Duration; + + const TIMEOUT: Duration = Duration::from_secs(5); + + fn open(path: &Path) -> Result { + let path: &SafePath = path.try_into().unwrap(); + Cgroup::open(path) + } + + struct Scratch(PathBuf); + + impl Scratch { + fn new(test: &str) -> Option { + if !nix::unistd::geteuid().is_root() { + eprintln!("SKIP {test}: needs root to create a cgroup"); + return None; + } + let path = Path::new(ROOT).join(format!("hyper-test-{}-{test}", std::process::id())); + match fs::create_dir(&path) { + Ok(()) => Some(Self(path)), + Err(e) => { + eprintln!("SKIP {test}: creating {path:?}: {e}"); + None + } + } + } + + fn spawn_member(&self) -> Child { + let child = Command::new("sleep").arg("600").spawn().unwrap(); + fs::write(self.0.join("cgroup.procs"), child.id().to_string()).unwrap(); + child + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::write(self.0.join("cgroup.kill"), "1"); + for _ in 0..50 { + if fs::remove_dir(&self.0).is_ok() || !self.0.exists() { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + } + } + + #[test] + fn open_refuses_a_directory_that_is_not_on_cgroup2() { + let dir = tempfile::tempdir().unwrap(); + let leaf = dir.path().join("leaf"); + fs::create_dir(&leaf).unwrap(); + + let got = open(&leaf); + + assert!( + matches!(got, Err(OpenError::NotCgroup(ref p)) if *p == leaf), + "{:?}", + got.err() + ); + } + + #[test] + fn a_populated_cgroup_is_only_removable_after_kill_procs_as_root() { + let Some(scratch) = Scratch::new("kill-then-remove") else { + return; + }; + let mut members = [scratch.spawn_member(), scratch.spawn_member()]; + + assert_eq!(open(&scratch.0).unwrap().remove(), Err(Errno::EBUSY)); + + let cgroup = open(&scratch.0).unwrap(); + cgroup.kill_procs(TIMEOUT).unwrap(); + assert!(!cgroup.events().unwrap().read().unwrap().populated); + cgroup.remove().unwrap(); + + assert!(!scratch.0.exists()); + for member in &mut members { + assert_eq!(member.wait().unwrap().signal(), Some(SIGKILL)); + } + } + + #[test] + fn kill_procs_and_remove_succeed_once_the_cgroup_is_gone_as_root() { + let Some(scratch) = Scratch::new("vanished") else { + return; + }; + let cgroup = open(&scratch.0).unwrap(); + fs::remove_dir(&scratch.0).unwrap(); + + cgroup.kill_procs(TIMEOUT).unwrap(); + cgroup.remove().unwrap(); + } +} diff --git a/native/suidhelper/src/linux/dm/thin_dump.rs b/native/suidhelper/src/linux/dm/thin_dump.rs index 52c48ac2..414d769f 100644 --- a/native/suidhelper/src/linux/dm/thin_dump.rs +++ b/native/suidhelper/src/linux/dm/thin_dump.rs @@ -176,3 +176,213 @@ impl Mapping { } } } + +#[cfg(test)] +mod tests { + use super::{BlockRange, MappingsError, MetadataDump, ParseError, ThinId, ThinMappings}; + use proptest::prelude::*; + + #[derive(Debug, Clone)] + enum M { + Range { begin: u64, len: u64 }, + Single { block: u64 }, + } + + impl M { + fn len(&self) -> u64 { + match *self { + M::Range { len, .. } => len, + M::Single { .. } => 1, + } + } + + fn range(&self) -> BlockRange { + match *self { + M::Range { begin, len } => BlockRange { begin, length: len }, + M::Single { block } => BlockRange { + begin: block, + length: 1, + }, + } + } + } + + fn mapping() -> impl Strategy { + prop_oneof![ + (0u64..1 << 20, 1u64..512).prop_map(|(begin, len)| M::Range { begin, len }), + (0u64..1 << 20).prop_map(|block| M::Single { block }), + ] + } + + fn mapped_blocks(maps: &[M]) -> u64 { + maps.iter().map(M::len).sum() + } + + fn render_device(dev_id: u32, mapped: u64, maps: &[M]) -> String { + let open = format!( + " \n"); + } + + let mut s = format!("{open}>\n"); + for m in maps { + s.push_str(&match *m { + M::Range { begin, len } => format!( + " \n" + ), + M::Single { block } => format!( + " \n" + ), + }); + } + s.push_str(" \n"); + s + } + + fn render(block_sectors: u64, devices: &[(u32, u64, Vec)]) -> String { + let mut s = format!( + "\n" + ); + for (id, mapped, maps) in devices { + s.push_str(&render_device(*id, *mapped, maps)); + } + s.push_str("\n"); + s + } + + fn device(id: u32, maps: Vec) -> (u32, u64, Vec) { + (id, mapped_blocks(&maps), maps) + } + + fn id(n: u32) -> ThinId { + ThinId::new(n).unwrap() + } + + fn dump(xml: impl Into>) -> MetadataDump { + MetadataDump { xml: xml.into() } + } + + fn mappings(xml: &str, n: u32) -> Result { + dump(xml).parse().unwrap().mappings(id(n)) + } + + proptest! { + #[test] + fn extracts_exactly_the_target_devices_ranges( + block_sectors in 1u64..4096, + target in proptest::collection::vec(mapping(), 0..64), + sibling in proptest::collection::vec(mapping(), 0..64), + ) { + let xml = render( + block_sectors, + &[device(1, sibling.clone()), device(3, target.clone()), device(5, sibling)], + ); + + let got = mappings(&xml, 3).unwrap(); + + prop_assert_eq!(got.block_sectors, block_sectors); + prop_assert_eq!(got.ranges, target.iter().map(M::range).collect::>()); + } + + #[test] + fn absent_device_is_an_error_not_empty( + maps in proptest::collection::vec(mapping(), 0..16), + ) { + let xml = render(128, &[device(1, maps)]); + prop_assert!(matches!(mappings(&xml, 99), Err(MappingsError::DeviceNotFound(got)) if got == id(99))); + } + + #[test] + fn mapped_blocks_disagreeing_with_the_mappings_is_an_error( + target in proptest::collection::vec(mapping(), 0..64), + skew in prop_oneof![1i64..64, -64i64..0], + ) { + let actual = mapped_blocks(&target); + let claimed = actual.saturating_add_signed(skew); + prop_assume!(claimed != actual); + let xml = render(128, &[(3, claimed, target)]); + + let got = mappings(&xml, 3); + + let is_truncated = matches!( + got, + Err(MappingsError::Truncated { expected, got }) if expected == claimed && got == actual + ); + prop_assert!(is_truncated); + } + } + + #[test] + fn missing_superblock_is_an_error() { + let got = dump("").parse(); + assert!(matches!(got, Err(ParseError::Xml(_))), "{got:?}"); + } + + #[test] + fn non_utf8_output_is_an_error() { + let mut xml = render(128, &[device(3, vec![])]).into_bytes(); + xml.insert(1, 0xff); + let got = dump(xml).parse(); + assert!(matches!(got, Err(ParseError::Utf8(_))), "{got:?}"); + } + + #[test] + fn empty_self_closing_device_parses_to_no_ranges() { + let xml = render(128, &[device(3, vec![])]); + assert_eq!( + mappings(&xml, 3).unwrap(), + ThinMappings { + block_sectors: 128, + ranges: vec![] + } + ); + } + + #[test] + fn attribute_lookup_is_by_exact_name() { + let xml = "\n \n \n \n\n"; + assert_eq!( + mappings(xml, 3).unwrap().ranges, + [BlockRange { + begin: 0, + length: 1 + }] + ); + } + + #[test] + fn layout_variance_is_irrelevant() { + let xml = ""; + assert_eq!( + mappings(xml, 3).unwrap(), + ThinMappings { + block_sectors: 128, + ranges: vec![ + BlockRange { + begin: 0, + length: 8 + }, + BlockRange { + begin: 42, + length: 1 + }, + ] + } + ); + } + + #[test] + fn malformed_or_cut_xml_is_an_error_not_a_guess() { + let whole = render(128, &[device(3, vec![M::Range { begin: 0, len: 8 }])]); + let cut: String = whole.lines().take(3).map(|l| format!("{l}\n")).collect(); + for xml in [ + " Date: Thu, 29 Oct 2026 00:25:17 -0700 Subject: [PATCH 16/17] patch up tests and do not run with .so's loaded --- .github/workflows/ci.yml | 2 +- lib/mix/tasks/suidhelper.install.ex | 4 +- mix.exs | 4 +- native/suidhelper/Cargo.toml | 8 -- native/suidhelper/src/process/binary.rs | 71 +++++++++++ native/suidhelper/src/process/privilege.rs | 110 ++++++++++++++++-- native/suidhelper/src/tools/network/exec.rs | 4 +- .../suidhelper/src/tools/network/host_init.rs | 2 +- .../suidhelper/src/tools/network/prepare.rs | 4 +- .../suidhelper/src/tools/network/teardown.rs | 2 +- native/suidhelper/tests/process/privilege.rs | 22 ---- native/suidhelper/tests/util/safe_bin.rs | 67 ----------- native/suidhelper/xtask/src/stamp.rs | 51 +++++++- 13 files changed, 227 insertions(+), 124 deletions(-) delete mode 100644 native/suidhelper/tests/process/privilege.rs delete mode 100644 native/suidhelper/tests/util/safe_bin.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc1aadb0..2492964e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -528,7 +528,7 @@ jobs: -o suidhelper.profdata "$TOOLS/llvm-cov" export --format=lcov \ --instr-profile=suidhelper.profdata \ - target/release/hyper-suidhelper \ + target/hyper-suidhelper \ "$GITHUB_WORKSPACE/native/suidhelper/src" \ > lcov-e2e.info sed -i "s|^SF:$GITHUB_WORKSPACE/|SF:|" lcov-e2e.info diff --git a/lib/mix/tasks/suidhelper.install.ex b/lib/mix/tasks/suidhelper.install.ex index a3a92086..d5164193 100644 --- a/lib/mix/tasks/suidhelper.install.ex +++ b/lib/mix/tasks/suidhelper.install.ex @@ -31,7 +31,7 @@ defmodule Mix.Tasks.Suidhelper.Install do # dependency: there the crate lives under deps/hypervm/native/suidhelper, not # under the consumer project's root. @helper_dir Path.expand("../../../native/suidhelper", __DIR__) - @source Path.join(@helper_dir, "target/release/hyper-suidhelper") + @source Path.join(@helper_dir, "target/hyper-suidhelper") # Must match `Hyper.Cfg.Tools.suidhelper/0`'s default path and the xtask's # `INSTALL_PATH`: a `PATH` location the unprivileged node can exec. @install_path "/usr/local/bin/hyper-suidhelper" @@ -54,7 +54,7 @@ defmodule Mix.Tasks.Suidhelper.Install do Mix.raise(""" `cargo xtask stamp` failed building the suidhelper. - Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml) + Ensure `cargo`, the helper's toolchain and its musl target (see #{@helper_dir}/rust-toolchain.toml) are installed. """) end diff --git a/mix.exs b/mix.exs index 47e56cef..dc6e7f05 100644 --- a/mix.exs +++ b/mix.exs @@ -501,7 +501,7 @@ defmodule Mix.Tasks.Compile.SuidhelperStamp do use Mix.Task.Compiler @helper_dir "native/suidhelper" - @binary "native/suidhelper/target/release/hyper-suidhelper" + @binary "native/suidhelper/target/hyper-suidhelper" @out "lib/hyper/suid_helper/expected.ex" @impl Mix.Task.Compiler @@ -522,7 +522,7 @@ defmodule Mix.Tasks.Compile.SuidhelperStamp do `cargo xtask stamp` failed (exit #{code}) building the suidhelper: #{output} - Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml) + Ensure `cargo`, the helper's toolchain and its musl target (see #{@helper_dir}/rust-toolchain.toml) are installed. """) end diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index 81f0040f..92b12922 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -41,14 +41,6 @@ path = "tests/security_gate.rs" name = "config_seam" path = "tests/config_seam.rs" -[[test]] -name = "util_safe_bin" -path = "tests/util/safe_bin.rs" - -[[test]] -name = "process_privilege" -path = "tests/process/privilege.rs" - [[test]] name = "tools_dmsetup_parsers" path = "tests/tools/dmsetup_parsers.rs" diff --git a/native/suidhelper/src/process/binary.rs b/native/suidhelper/src/process/binary.rs index b77ed358..23aeba78 100644 --- a/native/suidhelper/src/process/binary.rs +++ b/native/suidhelper/src/process/binary.rs @@ -99,3 +99,74 @@ impl RootSafeBinPathBuf { &self.0 } } + +#[cfg(test)] +mod tests { + //! `RootSafeBin` is what stops a configured path from pointing the helper at + //! an attacker-controlled binary it would then run as root. The constructor demands + //! an absolute path, exact basename, a real (non-symlink) regular file owned by + //! root and not group/other-writable. These assert the refusal axes; the symlink + //! axis is root-independent, the owner axis is asserted both ways. + + use super::RootSafeBin; + use std::fs; + use std::os::unix::fs::{symlink, PermissionsExt}; + use std::path::Path; + + fn is_root() -> bool { + nix::unistd::geteuid().is_root() + } + + #[test] + fn rejects_relative_path() { + assert!(RootSafeBin::<"dmsetup">::open(Path::new("dmsetup")).is_err()); + assert!(RootSafeBin::<"dmsetup">::open(Path::new("./dmsetup")).is_err()); + } + + #[test] + fn rejects_wrong_basename() { + // An absolute, existing, root-owned system file with the WRONG basename fails. + assert!(RootSafeBin::<"dmsetup">::open(Path::new("/usr/bin/env")).is_err()); + } + + #[test] + fn rejects_symlink_with_correct_basename() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target"); + fs::write(&target, b"x").unwrap(); + let link = dir.path().join("dmsetup"); + symlink(&target, &link).unwrap(); + // Symlink is checked before ownership, so this holds regardless of who runs it. + assert!(RootSafeBin::<"dmsetup">::open(&link).is_err()); + } + + #[test] + fn owner_axis_root_owned_accepted_else_rejected() { + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("dmsetup"); + fs::write(&f, b"#!/bin/true\n").unwrap(); + fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap(); + let got = RootSafeBin::<"dmsetup">::open(&f); + if is_root() { + // root-owned, 0755, absolute, correct basename, not a symlink → valid. + assert!(got.is_ok(), "root-owned valid bin rejected: {got:?}"); + } else { + // We own it (uid != 0) → NotRoot. + assert!(got.is_err(), "non-root-owned bin accepted"); + } + } + + #[test] + fn rejects_group_or_other_writable() { + if is_root() { + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("dmsetup"); + fs::write(&f, b"x").unwrap(); + // root-owned but group/other-writable → Writable rejection. + fs::set_permissions(&f, fs::Permissions::from_mode(0o757)).unwrap(); + assert!(RootSafeBin::<"dmsetup">::open(&f).is_err()); + } else { + eprintln!("SKIP rejects_group_or_other_writable: needs root to own a 0757 file"); + } + } +} diff --git a/native/suidhelper/src/process/privilege.rs b/native/suidhelper/src/process/privilege.rs index 50cd9ea5..ba44c988 100644 --- a/native/suidhelper/src/process/privilege.rs +++ b/native/suidhelper/src/process/privilege.rs @@ -2,12 +2,14 @@ use derive_more::Display; use nix::errno::Errno; +use nix::fcntl::{fcntl, FcntlArg, FdFlag}; use nix::libc::{self, c_uint}; use nix::unistd::{ getegid, geteuid, getgid, getuid, setegid, seteuid, setgroups, setresgid, setresuid, Gid, Uid, }; use std::io; use std::marker::PhantomData; +use std::os::unix::io::{AsRawFd, BorrowedFd, RawFd}; use std::os::unix::process::CommandExt; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; @@ -19,7 +21,7 @@ static TAKEN: AtomicBool = AtomicBool::new(false); macro_rules! setuid_main { ($entry:path) => { #[used] - #[link_section = ".init_array"] + #[link_section = ".init_array.00000"] static __HYPER_DROP_PRIVILEGES: extern "C" fn() = $crate::process::privilege::drop_to_real; fn main() { @@ -41,13 +43,27 @@ macro_rules! setuid_main { /// /// Exits if it fails. pub extern "C" fn drop_to_real() { + // SAFETY: getauxval only reads the auxiliary vector libc saved at startup. + let (secure, interpreter) = unsafe { + ( + libc::getauxval(libc::AT_SECURE), + libc::getauxval(libc::AT_BASE), + ) + }; + if secure != 0 && interpreter != 0 { + startup_abort(b"hyper-suidhelper: refusing to run setuid as a dynamically linked binary\n"); + } + if setegid(getgid()).is_err() || seteuid(getuid()).is_err() { - const MSG: &[u8] = b"hyper-suidhelper: failed to drop privileges at startup\n"; - // SAFETY: raw FFI to async-signal-safe libc calls; MSG is a static buffer. - unsafe { - let _ = libc::write(2, MSG.as_ptr().cast(), MSG.len()); - libc::_exit(1); - } + startup_abort(b"hyper-suidhelper: failed to drop privileges at startup\n"); + } +} + +fn startup_abort(msg: &[u8]) -> ! { + // SAFETY: raw FFI to async-signal-safe libc calls on a caller-owned buffer. + unsafe { + let _ = libc::write(2, msg.as_ptr().cast(), msg.len()); + libc::_exit(1) } } @@ -81,6 +97,12 @@ pub enum Error { }, #[error("marking inherited fds close-on-exec: {0}")] MarkCloexec(#[source] Errno), + #[error("inheriting fd {fd}: {errno}")] + Inherit { + fd: RawFd, + #[source] + errno: Errno, + }, } #[derive(Debug, ThisError)] @@ -137,10 +159,10 @@ pub trait RootCommandExt { /// Make children spawned from this command run as genuine root, like /// [`exec_as_root`](Self::exec_as_root). Only the child is affected. - fn spawn_as_root(&mut self, root: &Root<'_>) -> &mut Self; + fn spawn_as_root(&mut self, root: &Root<'_>, inherit: &[BorrowedFd<'_>]) -> &mut Self; } -fn seal_root() -> io::Result<()> { +fn seal_root(inherit: &[RawFd]) -> io::Result<()> { // SAFETY: close_range takes no memory operands. let rc = unsafe { libc::syscall( @@ -155,6 +177,11 @@ fn seal_root() -> io::Result<()> { return Err(io::Error::other(Error::MarkCloexec(Errno::last()))); } + for &fd in inherit { + fcntl(fd, FcntlArg::F_SETFD(FdFlag::empty())) + .map_err(|errno| io::Error::other(Error::Inherit { fd, errno }))?; + } + let seal = |step| move |errno| io::Error::other(Error::Seal { step, errno }); seteuid(ROOT_UID).map_err(seal(SealStep::Euid))?; @@ -170,7 +197,7 @@ impl RootCommandExt for Command { self.env_clear(); // SAFETY: `exec` runs this hook in the current process (there is no fork), right before // `execve`. - unsafe { self.pre_exec(seal_root) }; + unsafe { self.pre_exec(|| seal_root(&[])) }; let err = self.exec(); eprintln!("hyper-suidhelper: exec as root failed: {err}"); @@ -178,11 +205,12 @@ impl RootCommandExt for Command { unsafe { libc::_exit(2) } } - fn spawn_as_root(&mut self, _root: &Root<'_>) -> &mut Self { + fn spawn_as_root(&mut self, _root: &Root<'_>, inherit: &[BorrowedFd<'_>]) -> &mut Self { + let inherit: Vec = inherit.iter().map(AsRawFd::as_raw_fd).collect(); self.env_clear(); // SAFETY: the hook runs in the forked child right before `execve`, and `seal_root` makes // only async-signal-safe syscalls and allocates nothing on its success path. - unsafe { self.pre_exec(seal_root) }; + unsafe { self.pre_exec(move || seal_root(&inherit)) }; self } } @@ -196,3 +224,61 @@ impl Drop for Root<'_> { } } } + +#[cfg(test)] +mod tests { + //! `Unprivileged` is the single token every privilege transition borrows or + //! consumes, so nesting is ruled out only if at most one ever exists. Law: the + //! first `take` in a process yields it and every later `take` refuses, even + //! after the first token is dropped. + + use super::{RootCommandExt, TakeError, Unprivileged}; + use nix::fcntl::{fcntl, FcntlArg, FdFlag}; + use nix::unistd::geteuid; + use std::fs::File; + use std::os::unix::io::{AsFd, AsRawFd}; + use std::process::Command; + + #[test] + fn at_most_one_token_per_process() { + { + let first = Unprivileged::take(); + assert!(first.is_ok(), "first take must yield the token: {first:?}"); + assert!( + matches!(Unprivileged::take(), Err(TakeError::AlreadyTaken)), + "second take while held" + ); + } + assert!( + matches!(Unprivileged::take(), Err(TakeError::AlreadyTaken)), + "take after the first is dropped" + ); + } + + #[test] + fn a_root_child_inherits_exactly_the_listed_fds_as_root() { + if !geteuid().is_root() { + eprintln!("SKIP a_root_child_inherits: needs root to elevate"); + return; + } + let listed = File::open("/dev/null").unwrap(); + let unlisted = File::open("/dev/null").unwrap(); + fcntl(unlisted.as_raw_fd(), FcntlArg::F_SETFD(FdFlag::empty())).unwrap(); + + let mut token = Unprivileged::take().unwrap(); + let root = token.elevate().unwrap(); + let out = Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "for fd in {} {}; do if [ -e /proc/self/fd/$fd ]; then printf y; else printf n; fi; done", + listed.as_raw_fd(), + unlisted.as_raw_fd() + )) + .spawn_as_root(&root, &[listed.as_fd()]) + .output() + .unwrap(); + + assert!(out.status.success(), "{out:?}"); + assert_eq!(String::from_utf8_lossy(&out.stdout), "yn"); + } +} diff --git a/native/suidhelper/src/tools/network/exec.rs b/native/suidhelper/src/tools/network/exec.rs index 5543d4e0..7fa6b406 100644 --- a/native/suidhelper/src/tools/network/exec.rs +++ b/native/suidhelper/src/tools/network/exec.rs @@ -6,6 +6,7 @@ //! and `host_init` differ only in which commands they hand to [`run_all`]. use super::args::{Command, Which}; use crate::process::privilege::{Root, RootCommandExt}; +use std::os::unix::io::BorrowedFd; use std::process::Command as Proc; use thiserror::Error as ThisError; @@ -31,6 +32,7 @@ pub enum Error { pub(super) fn run_all( commands: &[Command], root: &Root<'_>, + inherit: &[BorrowedFd<'_>], resolve: impl Fn(Which) -> Proc, ) -> Result<(), Error> { for command in commands { @@ -44,7 +46,7 @@ pub(super) fn run_all( // same reason the jailer handoff uses `become_root_permanently`. This // runs in the forked child, so it never affects the parent's own // privilege window. - proc.args(&command.argv).spawn_as_root(root); + proc.args(&command.argv).spawn_as_root(root, inherit); let output = proc.output().map_err(|source| Error::Spawn { bin: command.bin, diff --git a/native/suidhelper/src/tools/network/host_init.rs b/native/suidhelper/src/tools/network/host_init.rs index d59b2a96..572f1217 100644 --- a/native/suidhelper/src/tools/network/host_init.rs +++ b/native/suidhelper/src/tools/network/host_init.rs @@ -98,7 +98,7 @@ impl IsTool for HostInit { let commands = args::host_init_commands(&self.uplink, &self.clone_pool); // host_init_commands only ever issues `nft` commands (see args.rs). - exec::run_all(&commands, root, |_which| self.nft.command())?; + exec::run_all(&commands, root, &[], |_which| self.nft.command())?; Ok(()) } diff --git a/native/suidhelper/src/tools/network/prepare.rs b/native/suidhelper/src/tools/network/prepare.rs index 47dabca5..8a71749c 100644 --- a/native/suidhelper/src/tools/network/prepare.rs +++ b/native/suidhelper/src/tools/network/prepare.rs @@ -11,6 +11,7 @@ use crate::tools::jailer::{self, VmId}; use crate::tools::IsTool; use clap::Args; use std::net::Ipv4Addr; +use std::os::unix::io::AsFd; use thiserror::Error as ThisError; #[derive(Args)] @@ -139,7 +140,8 @@ impl IsTool for Prepare { &self.nft.fd_path().to_string_lossy(), &self.sysctl.fd_path().to_string_lossy(), ); - exec::run_all(&commands, root, |which| match which { + let inherit = [self.nft.as_fd(), self.sysctl.as_fd()]; + exec::run_all(&commands, root, &inherit, |which| match which { args::Which::Ip => self.ip.command(), args::Which::Nft => self.nft.command(), })?; diff --git a/native/suidhelper/src/tools/network/teardown.rs b/native/suidhelper/src/tools/network/teardown.rs index d65ccc71..6305dec8 100644 --- a/native/suidhelper/src/tools/network/teardown.rs +++ b/native/suidhelper/src/tools/network/teardown.rs @@ -60,7 +60,7 @@ impl IsTool for Teardown { fn run_privileged(&self, root: &Root<'_>) -> Self::RunT { let commands = args::teardown_commands(&self.plan); - exec::run_all(&commands, root, |which| match which { + exec::run_all(&commands, root, &[], |which| match which { args::Which::Ip => self.ip.command(), args::Which::Nft => self.nft.command(), })?; diff --git a/native/suidhelper/tests/process/privilege.rs b/native/suidhelper/tests/process/privilege.rs deleted file mode 100644 index 374de292..00000000 --- a/native/suidhelper/tests/process/privilege.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! `Unprivileged` is the single token every privilege transition borrows or -//! consumes, so nesting is ruled out only if at most one ever exists. Law: the -//! first `take` in a process yields it and every later `take` refuses, even -//! after the first token is dropped. - -use hyper_suidhelper::process::privilege::{TakeError, Unprivileged}; - -#[test] -fn at_most_one_token_per_process() { - { - let first = Unprivileged::take(); - assert!(first.is_ok(), "first take must yield the token: {first:?}"); - assert!( - matches!(Unprivileged::take(), Err(TakeError::AlreadyTaken)), - "second take while held" - ); - } - assert!( - matches!(Unprivileged::take(), Err(TakeError::AlreadyTaken)), - "take after the first is dropped" - ); -} diff --git a/native/suidhelper/tests/util/safe_bin.rs b/native/suidhelper/tests/util/safe_bin.rs deleted file mode 100644 index c477e3ad..00000000 --- a/native/suidhelper/tests/util/safe_bin.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! `RootSafeBin` is what stops a configured path from pointing the helper at -//! an attacker-controlled binary it would then run as root. The constructor demands -//! an absolute path, exact basename, a real (non-symlink) regular file owned by -//! root and not group/other-writable. These assert the refusal axes; the symlink -//! axis is root-independent, the owner axis is asserted both ways. - -use hyper_suidhelper::process::binary::RootSafeBin; -use std::fs; -use std::os::unix::fs::{symlink, PermissionsExt}; -use std::path::Path; - -fn is_root() -> bool { - nix::unistd::geteuid().is_root() -} - -#[test] -fn rejects_relative_path() { - assert!(RootSafeBin::<"losetup">::open(Path::new("losetup")).is_err()); - assert!(RootSafeBin::<"losetup">::open(Path::new("./losetup")).is_err()); -} - -#[test] -fn rejects_wrong_basename() { - // An absolute, existing, root-owned system file with the WRONG basename fails. - assert!(RootSafeBin::<"losetup">::open(Path::new("/usr/bin/env")).is_err()); -} - -#[test] -fn rejects_symlink_with_correct_basename() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - fs::write(&target, b"x").unwrap(); - let link = dir.path().join("losetup"); - symlink(&target, &link).unwrap(); - // Symlink is checked before ownership, so this holds regardless of who runs it. - assert!(RootSafeBin::<"losetup">::open(&link).is_err()); -} - -#[test] -fn owner_axis_root_owned_accepted_else_rejected() { - let dir = tempfile::tempdir().unwrap(); - let f = dir.path().join("losetup"); - fs::write(&f, b"#!/bin/true\n").unwrap(); - fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap(); - let got = RootSafeBin::<"losetup">::open(&f); - if is_root() { - // root-owned, 0755, absolute, correct basename, not a symlink → valid. - assert!(got.is_ok(), "root-owned valid bin rejected: {got:?}"); - } else { - // We own it (uid != 0) → NotRoot. - assert!(got.is_err(), "non-root-owned bin accepted"); - } -} - -#[test] -fn rejects_group_or_other_writable() { - if is_root() { - let dir = tempfile::tempdir().unwrap(); - let f = dir.path().join("losetup"); - fs::write(&f, b"x").unwrap(); - // root-owned but group/other-writable → Writable rejection. - fs::set_permissions(&f, fs::Permissions::from_mode(0o757)).unwrap(); - assert!(RootSafeBin::<"losetup">::open(&f).is_err()); - } else { - eprintln!("SKIP rejects_group_or_other_writable: needs root to own a 0757 file"); - } -} diff --git a/native/suidhelper/xtask/src/stamp.rs b/native/suidhelper/xtask/src/stamp.rs index 0703ca89..0adfcfe3 100644 --- a/native/suidhelper/xtask/src/stamp.rs +++ b/native/suidhelper/xtask/src/stamp.rs @@ -5,9 +5,11 @@ //! `hyper-suidhelper version`; a verifier re-zeroes the section, rehashes, and //! compares. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use hyper_suidhelper_meta::{CHECKSUM_LEN, CHECKSUM_SECTION as SECTION}; +use object::elf::PT_INTERP; +use object::read::elf::ProgramHeader; use object::{Object, ObjectSection}; use crate::{target_dir, BIN}; @@ -16,8 +18,10 @@ use crate::{target_dir, BIN}; /// the stamped ELF. pub fn run() -> PathBuf { let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let target = format!("{}-unknown-linux-musl", std::env::consts::ARCH); + require_target(&target); let mut build = std::process::Command::new(cargo); - build.args(["build", "--release", "-p", BIN]); + build.args(["build", "--release", "--target", &target, "-p", BIN]); // CI coverage seam: instrument ONLY the helper build. A job-wide RUSTFLAGS // would also instrument the guest agent, which runs as PID 1 inside the // guest where no profile can be collected. Appending (not replacing) @@ -34,11 +38,16 @@ pub fn run() -> PathBuf { flags.push_str("-C instrument-coverage -C llvm-args=-runtime-counter-relocation"); build.env("RUSTFLAGS", flags); } - let built = build.status().expect("failed to spawn cargo"); - assert!(built.success(), "cargo build --release failed"); + let status = build.status().expect("failed to spawn cargo"); + assert!(status.success(), "cargo build --release failed"); - let path = target_dir().join("release").join(BIN); - let mut bytes = std::fs::read(&path).expect("failed to read release binary"); + let built = target_dir().join(&target).join("release").join(BIN); + let mut bytes = std::fs::read(&built).expect("failed to read release binary"); + assert!( + !has_interpreter(&bytes), + "{} is dynamically linked; the setuid helper must be static", + built.display() + ); let (offset, size) = section_range(&bytes); assert_eq!(size, CHECKSUM_LEN, "{SECTION} must be {CHECKSUM_LEN} bytes"); @@ -49,11 +58,41 @@ pub fn run() -> PathBuf { let digest = blake3::hash(&bytes); bytes[offset..offset + size].copy_from_slice(digest.as_bytes()); + let path = target_dir().join(BIN); + std::fs::copy(&built, &path).expect("failed to copy release binary"); std::fs::write(&path, &bytes).expect("failed to write stamped binary"); println!("stamped {} -> {}", path.display(), digest.to_hex()); path } +fn require_target(target: &str) { + let crate_dir = target_dir() + .parent() + .expect("target dir has no parent") + .to_path_buf(); + let out = std::process::Command::new("rustc") + .args(["--print", "target-libdir", "--target", target]) + .current_dir(&crate_dir) + .output() + .expect("failed to spawn rustc"); + let libdir = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success() && Path::new(libdir.trim()).is_dir(), + "the {target} target is not installed for the helper's toolchain; run \ + `rustup target add {target}` in {}", + crate_dir.display() + ); +} + +fn has_interpreter(bytes: &[u8]) -> bool { + let object::File::Elf64(elf) = object::File::parse(bytes).expect("failed to parse ELF") else { + panic!("expected a 64-bit ELF"); + }; + elf.elf_program_headers() + .iter() + .any(|header| header.p_type(elf.endian()) == PT_INTERP) +} + /// File offset and size of the checksum section in the given ELF image. fn section_range(bytes: &[u8]) -> (usize, usize) { let elf = object::File::parse(bytes).expect("failed to parse ELF"); From d6bb2e6734c91fa2658cf8f98aa11e5210441634 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 29 Oct 2026 00:28:22 -0700 Subject: [PATCH 17/17] patch up provisioning script in CI --- .github/scripts/provision-kvm-host.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/scripts/provision-kvm-host.sh b/.github/scripts/provision-kvm-host.sh index 00da31d8..3bf983aa 100755 --- a/.github/scripts/provision-kvm-host.sh +++ b/.github/scripts/provision-kvm-host.sh @@ -34,8 +34,14 @@ sudo udevadm trigger --name-match=kvm sudo apt-get update sudo apt-get install -y \ - coreutils e2fsprogs iproute2 libc-bin lvm2 nftables skopeo thin-provisioning-tools util-linux \ - "linux-modules-extra-$(uname -r)" + coreutils e2fsprogs iproute2 libc-bin lvm2 nftables skopeo thin-provisioning-tools util-linux + +modules_extra="linux-modules-extra-$(uname -r)" +if apt-cache show "${modules_extra}" >/dev/null 2>&1; then + sudo apt-get install -y "${modules_extra}" +else + echo "${modules_extra} is not available; relying on the kernel's base modules" +fi # -a is load-bearing: without it modprobe reads the 2nd+ names as module # PARAMETERS of the first, and the load fails.