Add fail-closed Linux process and descriptor primitives
This commit is contained in:
parent
b34421efa1
commit
1468f6b841
14 changed files with 1725 additions and 15 deletions
64
Cargo.lock
generated
64
Cargo.lock
generated
|
|
@ -2,6 +2,12 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cobs"
|
name = "cobs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -23,6 +29,16 @@ version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazaret-client"
|
name = "lazaret-client"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|
@ -52,6 +68,14 @@ dependencies = [
|
||||||
"lazaret-core",
|
"lazaret-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazaret-linux"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"lazaret-core",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazaret-network-sidecar"
|
name = "lazaret-network-sidecar"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|
@ -66,6 +90,18 @@ dependencies = [
|
||||||
"lazaret-core",
|
"lazaret-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "postcard"
|
name = "postcard"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
|
|
@ -96,6 +132,19 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -162,3 +211,18 @@ name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ members = [
|
||||||
"crates/lazaret-core",
|
"crates/lazaret-core",
|
||||||
"crates/lazaret-escalation-client",
|
"crates/lazaret-escalation-client",
|
||||||
"crates/lazaret-host-coordinator",
|
"crates/lazaret-host-coordinator",
|
||||||
|
"crates/lazaret-linux",
|
||||||
"crates/lazaret-network-sidecar",
|
"crates/lazaret-network-sidecar",
|
||||||
"crates/lazaret-runtime-supervisor",
|
"crates/lazaret-runtime-supervisor",
|
||||||
]
|
]
|
||||||
|
|
@ -18,6 +19,7 @@ license = "Apache-2.0"
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] }
|
postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] }
|
||||||
serde = { version = "=1.0.228", features = ["derive"] }
|
serde = { version = "=1.0.228", features = ["derive"] }
|
||||||
|
rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread"] }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
|
||||||
22
crates/lazaret-linux/Cargo.toml
Normal file
22
crates/lazaret-linux/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "lazaret-linux"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||||
|
rustix.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "lazaret-launch-wrapper"
|
||||||
|
path = "src/bin/lazaret-launch-wrapper.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "capability-drop-helper"
|
||||||
|
path = "tests/capability-drop-helper.rs"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
34
crates/lazaret-linux/src/bin/lazaret-launch-wrapper.rs
Normal file
34
crates/lazaret-linux/src/bin/lazaret-launch-wrapper.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use lazaret_linux::process::{
|
||||||
|
BarrierWait, arm_parent_death_and_recheck, exec_target, parse_wrapper_arguments,
|
||||||
|
};
|
||||||
|
use rustix::process::Signal;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("lazaret launch wrapper refused: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> std::io::Result<()> {
|
||||||
|
let arguments = std::env::args_os().skip(1).collect::<Vec<OsString>>();
|
||||||
|
if arguments == [OsString::from("--trace-self-test")] {
|
||||||
|
let (release, wait) = lazaret_linux::process::launch_barrier()?;
|
||||||
|
release.release()?;
|
||||||
|
wait.wait()?;
|
||||||
|
return Err(exec_target(lazaret_linux::process::TargetCommand::new(
|
||||||
|
"/bin/true",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let (expected_parent, target) = parse_wrapper_arguments(arguments)?;
|
||||||
|
BarrierWait::from_stdin()?.wait()?;
|
||||||
|
rustix::io::fcntl_setfd(std::io::stdin(), rustix::io::FdFlags::CLOEXEC)?;
|
||||||
|
arm_parent_death_and_recheck(Signal::KILL, expected_parent)?;
|
||||||
|
Err(exec_target(target))
|
||||||
|
}
|
||||||
98
crates/lazaret-linux/src/capability.rs
Normal file
98
crates/lazaret-linux/src/capability.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use rustix::thread::{
|
||||||
|
CapabilitySet, CapabilitySets, capabilities, capability_is_in_ambient_set,
|
||||||
|
capability_is_in_bounding_set, configure_capability_in_ambient_set,
|
||||||
|
remove_capability_from_bounding_set, set_capabilities,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CapabilityState {
|
||||||
|
pub sets: CapabilitySets,
|
||||||
|
pub ambient: bool,
|
||||||
|
pub bounding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when any kernel capability set cannot be inspected.
|
||||||
|
pub fn inspect(capability: CapabilitySet) -> io::Result<CapabilityState> {
|
||||||
|
Ok(CapabilityState {
|
||||||
|
sets: capabilities(None)?,
|
||||||
|
ambient: capability_is_in_ambient_set(capability)?,
|
||||||
|
bounding: capability_is_in_bounding_set(capability)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses any incomplete set, bounding, ambient, or secure-bit transition.
|
||||||
|
pub fn irreversibly_drop(capability: CapabilitySet) -> io::Result<()> {
|
||||||
|
require_single_threaded_process()?;
|
||||||
|
let before = inspect(capability)?;
|
||||||
|
if before.bounding && !before.sets.effective.contains(CapabilitySet::SETPCAP) {
|
||||||
|
return Err(io::Error::from_raw_os_error(1));
|
||||||
|
}
|
||||||
|
if before.ambient {
|
||||||
|
configure_capability_in_ambient_set(capability, false)?;
|
||||||
|
}
|
||||||
|
if before.bounding {
|
||||||
|
remove_capability_from_bounding_set(capability)?;
|
||||||
|
}
|
||||||
|
let mut sets = before.sets;
|
||||||
|
sets.effective.remove(capability);
|
||||||
|
sets.permitted.remove(capability);
|
||||||
|
sets.inheritable.remove(capability);
|
||||||
|
if sets != before.sets {
|
||||||
|
set_capabilities(None, sets)?;
|
||||||
|
}
|
||||||
|
let observed = inspect(capability)?;
|
||||||
|
if observed.sets.effective.intersects(capability)
|
||||||
|
|| observed.sets.permitted.intersects(capability)
|
||||||
|
|| observed.sets.inheritable.intersects(capability)
|
||||||
|
|| observed.ambient
|
||||||
|
|| observed.bounding
|
||||||
|
{
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"capability remained available after irreversible drop",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_single_threaded_process() -> io::Result<()> {
|
||||||
|
let mut tasks = std::fs::read_dir("/proc/self/task")?;
|
||||||
|
if tasks.next().transpose()?.is_some() && tasks.next().transpose()?.is_none() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other(
|
||||||
|
"capability transition requires a single-threaded process",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rustix::thread::CapabilitySet;
|
||||||
|
|
||||||
|
use super::{inspect, irreversibly_drop};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspects_current_capability_sets() {
|
||||||
|
let state = inspect(CapabilitySet::NET_ADMIN).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.sets.effective.contains(CapabilitySet::NET_ADMIN),
|
||||||
|
state.sets.effective.intersects(CapabilitySet::NET_ADMIN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multithreaded_process_refuses_before_transition() {
|
||||||
|
let before = inspect(CapabilitySet::NET_ADMIN).unwrap();
|
||||||
|
let waiting = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let child_waiting = std::sync::Arc::clone(&waiting);
|
||||||
|
let child = std::thread::spawn(move || child_waiting.wait());
|
||||||
|
assert!(irreversibly_drop(CapabilitySet::NET_ADMIN).is_err());
|
||||||
|
assert_eq!(inspect(CapabilitySet::NET_ADMIN).unwrap(), before);
|
||||||
|
waiting.wait();
|
||||||
|
child.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
268
crates/lazaret-linux/src/filesystem.rs
Normal file
268
crates/lazaret-linux/src/filesystem.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{self, Seek, SeekFrom, Write};
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use lazaret_core::identity::{DeviceId, InodeId, MountId, MountIdentity, NamespaceIdentity};
|
||||||
|
use rustix::fs::{
|
||||||
|
AtFlags, FlockOperation, MemfdFlags, SealFlags, StatxFlags, fcntl_add_seals, fcntl_get_seals,
|
||||||
|
flock, fstat, makedev, memfd_create, statx,
|
||||||
|
};
|
||||||
|
|
||||||
|
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
const REQUIRED_SEALS: SealFlags = SealFlags::SHRINK
|
||||||
|
.union(SealFlags::GROW)
|
||||||
|
.union(SealFlags::WRITE)
|
||||||
|
.union(SealFlags::SEAL);
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error on incomplete writes, sealing failures, or seal mismatch.
|
||||||
|
pub fn sealed_memfd(name: &str, bytes: &[u8]) -> io::Result<File> {
|
||||||
|
let fd = memfd_create(name, MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING)?;
|
||||||
|
let mut file = File::from(fd);
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.flush()?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
fcntl_add_seals(file.as_fd(), REQUIRED_SEALS)?;
|
||||||
|
require_write_sealed(file.as_fd())?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses descriptors without the complete immutable seal set.
|
||||||
|
pub fn require_write_sealed(fd: impl AsFd) -> io::Result<()> {
|
||||||
|
let seals = fcntl_get_seals(fd)?;
|
||||||
|
if seals.contains(REQUIRED_SEALS) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other("memfd lacks required immutable seals"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the nonblocking exclusive lock is unavailable.
|
||||||
|
pub fn try_exclusive_flock(file: File) -> io::Result<ExclusiveFileLock> {
|
||||||
|
flock(file.as_fd(), FlockOperation::NonBlockingLockExclusive)?;
|
||||||
|
Ok(ExclusiveFileLock(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExclusiveFileLock(File);
|
||||||
|
|
||||||
|
impl AsFd for ExclusiveFileLock {
|
||||||
|
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
|
||||||
|
self.0.as_fd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error unless the replacement and containing directory are synced.
|
||||||
|
pub fn atomic_replace(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
atomic_replace_with(path, |temporary| temporary.write_all(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic_replace_with<Write>(path: &Path, write: Write) -> io::Result<()>
|
||||||
|
where
|
||||||
|
Write: FnOnce(&mut File) -> io::Result<()>,
|
||||||
|
{
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| io::Error::other("replacement path has no parent"))?;
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| io::Error::other("replacement path has no file name"))?;
|
||||||
|
let mut temporary = None;
|
||||||
|
for _ in 0..128 {
|
||||||
|
let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut name = Vec::with_capacity(file_name.as_bytes().len() + 48);
|
||||||
|
name.extend_from_slice(b".");
|
||||||
|
name.extend_from_slice(file_name.as_bytes());
|
||||||
|
name.extend_from_slice(format!(".lazaret-{}-{sequence}", std::process::id()).as_bytes());
|
||||||
|
let path = parent.join(std::ffi::OsString::from_vec(name));
|
||||||
|
match OpenOptions::new().create_new(true).write(true).open(&path) {
|
||||||
|
Ok(file) => {
|
||||||
|
temporary = Some((path, file));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (temporary_path, mut temporary_file) =
|
||||||
|
temporary.ok_or_else(|| io::Error::other("temporary replacement name exhausted"))?;
|
||||||
|
let result = (|| {
|
||||||
|
write(&mut temporary_file)?;
|
||||||
|
temporary_file.sync_all()?;
|
||||||
|
std::fs::rename(&temporary_path, parent.join(file_name))?;
|
||||||
|
OpenOptions::new().read(true).open(parent)?.sync_all()
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = std::fs::remove_file(temporary_path);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the namespace descriptor cannot be opened or inspected.
|
||||||
|
pub fn namespace_identity(path: &Path) -> io::Result<NamespaceIdentity> {
|
||||||
|
let file = OpenOptions::new().read(true).open(path)?;
|
||||||
|
let stat = fstat(file.as_fd())?;
|
||||||
|
Ok(NamespaceIdentity {
|
||||||
|
device: DeviceId::new(stat.st_dev),
|
||||||
|
inode: InodeId::new(stat.st_ino),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the path has no exact readable mount identity.
|
||||||
|
pub fn current_mount_identity(path: &Path) -> io::Result<MountIdentity> {
|
||||||
|
let file = OpenOptions::new().read(true).open(path)?;
|
||||||
|
mount_identity(file.as_fd())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses when `statx(AT_EMPTY_PATH)` cannot return complete same-descriptor evidence.
|
||||||
|
pub fn mount_identity(fd: impl AsFd) -> io::Result<MountIdentity> {
|
||||||
|
let stat = statx(
|
||||||
|
fd.as_fd(),
|
||||||
|
"",
|
||||||
|
AtFlags::EMPTY_PATH,
|
||||||
|
StatxFlags::BASIC_STATS | StatxFlags::MNT_ID,
|
||||||
|
)?;
|
||||||
|
let mask = StatxFlags::from_bits_retain(stat.stx_mask);
|
||||||
|
if !mask.contains(StatxFlags::INO | StatxFlags::MNT_ID) {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"statx omitted required mount identity evidence",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(MountIdentity {
|
||||||
|
mount_id: MountId::new(stat.stx_mnt_id),
|
||||||
|
device: DeviceId::new(makedev(stat.stx_dev_major, stat.stx_dev_minor)),
|
||||||
|
inode: InodeId::new(stat.stx_ino),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses missing, unreadable, replaced, renamed, or remounted path identity.
|
||||||
|
pub fn require_mount_identity(path: &Path, expected: MountIdentity) -> io::Result<()> {
|
||||||
|
if current_mount_identity(path)? == expected {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other("mount identity mismatch"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
use rustix::fs::{MemfdFlags, memfd_create};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
atomic_replace, current_mount_identity, namespace_identity, require_mount_identity,
|
||||||
|
require_write_sealed, sealed_memfd, try_exclusive_flock,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn test_directory(name: &str) -> std::path::PathBuf {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"lazaret-linux-{name}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
super::TEMPORARY_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
std::fs::create_dir(&path).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seals_memfd_after_complete_write() {
|
||||||
|
let mut file = sealed_memfd("policy", b"complete bytes").unwrap();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes).unwrap();
|
||||||
|
assert_eq!(bytes, b"complete bytes");
|
||||||
|
assert!(file.write_all(b"mutation").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_unsealed_memfd() {
|
||||||
|
let fd = memfd_create("unsealed", MemfdFlags::ALLOW_SEALING).unwrap();
|
||||||
|
assert!(require_write_sealed(fd).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomically_replaces_and_identifies_mount() {
|
||||||
|
let directory = test_directory("replace");
|
||||||
|
let path = directory.join("record");
|
||||||
|
OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap()
|
||||||
|
.write_all(b"old")
|
||||||
|
.unwrap();
|
||||||
|
atomic_replace(&path, b"new").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||||
|
assert!(current_mount_identity(&path).is_ok());
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_replacement_write_preserves_durable_old_file() {
|
||||||
|
let directory = test_directory("partial-replace");
|
||||||
|
let path = directory.join("record");
|
||||||
|
std::fs::write(&path, b"old").unwrap();
|
||||||
|
let result = super::atomic_replace_with(&path, |temporary| {
|
||||||
|
temporary.write_all(b"partial")?;
|
||||||
|
Err(std::io::Error::other("injected partial write"))
|
||||||
|
});
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"old");
|
||||||
|
assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1);
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclusive_flock_refuses_competing_open_description() {
|
||||||
|
let directory = test_directory("flock");
|
||||||
|
let path = directory.join("lock");
|
||||||
|
let first = OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
let second = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
let held = try_exclusive_flock(first).unwrap();
|
||||||
|
assert!(try_exclusive_flock(second).is_err());
|
||||||
|
drop(held);
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_identity_is_stable_for_same_namespace() {
|
||||||
|
let first = namespace_identity(std::path::Path::new("/proc/self/ns/mnt")).unwrap();
|
||||||
|
let second = namespace_identity(std::path::Path::new("/proc/self/ns/mnt")).unwrap();
|
||||||
|
assert_eq!(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mount_identity_refuses_replaced_and_renamed_objects() {
|
||||||
|
let directory = test_directory("mount-race");
|
||||||
|
let path = directory.join("object");
|
||||||
|
std::fs::write(&path, b"first").unwrap();
|
||||||
|
let expected = current_mount_identity(&path).unwrap();
|
||||||
|
let old = directory.join("old");
|
||||||
|
std::fs::rename(&path, &old).unwrap();
|
||||||
|
std::fs::write(&path, b"replacement").unwrap();
|
||||||
|
assert!(require_mount_identity(&path, expected).is_err());
|
||||||
|
assert!(require_mount_identity(&old, expected).is_ok());
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/lazaret-linux/src/ipc.rs
Normal file
36
crates/lazaret-linux/src/ipc.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
use std::os::fd::OwnedFd;
|
||||||
|
|
||||||
|
use rustix::net::{AddressFamily, SocketFlags, SocketType, socketpair};
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the private sequenced-packet pair cannot be created.
|
||||||
|
pub fn private_seqpacket_pair() -> std::io::Result<(OwnedFd, OwnedFd)> {
|
||||||
|
socketpair(
|
||||||
|
AddressFamily::UNIX,
|
||||||
|
SocketType::SEQPACKET,
|
||||||
|
SocketFlags::CLOEXEC,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
|
||||||
|
use rustix::net::{RecvFlags, SendFlags, recv, send};
|
||||||
|
|
||||||
|
use super::private_seqpacket_pair;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_message_boundaries() {
|
||||||
|
let (left, right) = private_seqpacket_pair().unwrap();
|
||||||
|
send(left.as_fd(), b"first", SendFlags::empty()).unwrap();
|
||||||
|
send(left.as_fd(), b"second", SendFlags::empty()).unwrap();
|
||||||
|
let mut buffer = [0_u8; 16];
|
||||||
|
let (_, first) = recv(right.as_fd(), &mut buffer, RecvFlags::empty()).unwrap();
|
||||||
|
assert_eq!(&buffer[..first], b"first");
|
||||||
|
let (_, second) = recv(right.as_fd(), &mut buffer, RecvFlags::empty()).unwrap();
|
||||||
|
assert_eq!(&buffer[..second], b"second");
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/lazaret-linux/src/lib.rs
Normal file
8
crates/lazaret-linux/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#![doc = "Safe Linux x86-64 operating-system primitives used by Lazaret processes."]
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
pub mod capability;
|
||||||
|
pub mod filesystem;
|
||||||
|
pub mod ipc;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod process;
|
||||||
123
crates/lazaret-linux/src/platform.rs
Normal file
123
crates/lazaret-linux/src/platform.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub const MINIMUM_KERNEL_MAJOR: u64 = 6;
|
||||||
|
pub const MINIMUM_KERNEL_MINOR: u64 = 9;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PlatformObservation<'a> {
|
||||||
|
pub operating_system: &'a str,
|
||||||
|
pub architecture: &'a str,
|
||||||
|
pub kernel_release: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PlatformRefusal {
|
||||||
|
OperatingSystem(String),
|
||||||
|
Architecture(String),
|
||||||
|
KernelRelease(String),
|
||||||
|
KernelFloor { major: u64, minor: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PlatformRefusal {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::OperatingSystem(value) => {
|
||||||
|
write!(formatter, "unsupported operating system {value}")
|
||||||
|
}
|
||||||
|
Self::Architecture(value) => write!(formatter, "unsupported architecture {value}"),
|
||||||
|
Self::KernelRelease(value) => write!(formatter, "invalid Linux kernel release {value}"),
|
||||||
|
Self::KernelFloor { major, minor } => write!(
|
||||||
|
formatter,
|
||||||
|
"Linux kernel {major}.{minor} is below the required 6.9 floor"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PlatformRefusal {}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses unsupported operating systems, architectures, and kernel releases.
|
||||||
|
pub fn check_platform(observation: &PlatformObservation<'_>) -> Result<(), PlatformRefusal> {
|
||||||
|
if observation.operating_system != "linux" {
|
||||||
|
return Err(PlatformRefusal::OperatingSystem(
|
||||||
|
observation.operating_system.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if observation.architecture != "x86_64" {
|
||||||
|
return Err(PlatformRefusal::Architecture(
|
||||||
|
observation.architecture.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (major, minor) = parse_kernel_release(observation.kernel_release)?;
|
||||||
|
if (major, minor) < (MINIMUM_KERNEL_MAJOR, MINIMUM_KERNEL_MINOR) {
|
||||||
|
return Err(PlatformRefusal::KernelFloor { major, minor });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses releases without numeric major and minor components.
|
||||||
|
pub fn parse_kernel_release(release: &str) -> Result<(u64, u64), PlatformRefusal> {
|
||||||
|
let mut components = release.split('.');
|
||||||
|
let major = components
|
||||||
|
.next()
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.ok_or_else(|| PlatformRefusal::KernelRelease(release.into()))?;
|
||||||
|
let minor = components
|
||||||
|
.next()
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.split_once('-')
|
||||||
|
.map_or(value, |(number, _)| number)
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.ok_or_else(|| PlatformRefusal::KernelRelease(release.into()))?;
|
||||||
|
Ok((major, minor))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{PlatformObservation, PlatformRefusal, check_platform};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_floor_and_newer_linux_x86_64() {
|
||||||
|
for release in ["6.9.0", "6.9.12-custom", "6.10.0", "7.0"] {
|
||||||
|
check_platform(&PlatformObservation {
|
||||||
|
operating_system: "linux",
|
||||||
|
architecture: "x86_64",
|
||||||
|
kernel_release: release,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_older_kernel_and_other_platforms() {
|
||||||
|
let old = check_platform(&PlatformObservation {
|
||||||
|
operating_system: "linux",
|
||||||
|
architecture: "x86_64",
|
||||||
|
kernel_release: "6.8.14",
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
old,
|
||||||
|
Err(PlatformRefusal::KernelFloor { major: 6, minor: 8 })
|
||||||
|
);
|
||||||
|
|
||||||
|
for observation in [
|
||||||
|
PlatformObservation {
|
||||||
|
operating_system: "freebsd",
|
||||||
|
architecture: "x86_64",
|
||||||
|
kernel_release: "14.0",
|
||||||
|
},
|
||||||
|
PlatformObservation {
|
||||||
|
operating_system: "linux",
|
||||||
|
architecture: "aarch64",
|
||||||
|
kernel_release: "6.9.0",
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(check_platform(&observation).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
698
crates/lazaret-linux/src/process.rs
Normal file
698
crates/lazaret-linux/src/process.rs
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::os::fd::{AsFd, OwnedFd};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
BootId, HostPidLocator, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
||||||
|
PidfsProcessIdentityV1, ProcessStartTimeTicks,
|
||||||
|
};
|
||||||
|
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||||||
|
use rustix::fs::{fstat, fstatfs};
|
||||||
|
use rustix::io::{FdFlags, fcntl_setfd};
|
||||||
|
use rustix::pipe::{PipeFlags, pipe_with};
|
||||||
|
use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions};
|
||||||
|
|
||||||
|
pub const PID_FS_MAGIC: i64 = 0x5049_4446;
|
||||||
|
const BARRIER_RELEASE: &[u8; 32] = b"LAZARET-CAUSAL-BARRIER-RELEASE!!";
|
||||||
|
static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TargetCommand {
|
||||||
|
program: OsString,
|
||||||
|
arguments: Vec<OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetCommand {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(program: impl Into<OsString>) -> Self {
|
||||||
|
Self {
|
||||||
|
program: program.into(),
|
||||||
|
arguments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
|
||||||
|
self.arguments.push(argument.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_types)]
|
||||||
|
fn append_to(&self, command: &mut std::process::Command) {
|
||||||
|
command.arg("--").arg(&self.program).args(&self.arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RetainedProcess {
|
||||||
|
pidfd: OwnedFd,
|
||||||
|
identity: PidfsProcessIdentityV1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetainedProcess {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn identity(&self) -> PidfsProcessIdentityV1 {
|
||||||
|
self.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the exact retained-pidfd action fails.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn send_signal(&self, signal: Signal) -> io::Result<()> {
|
||||||
|
rustix::process::pidfd_send_signal(self.pidfd.as_fd(), signal).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when polling the retained pidfd fails.
|
||||||
|
pub fn has_exited(&self) -> io::Result<bool> {
|
||||||
|
let mut descriptors = [PollFd::new(&self.pidfd, PollFlags::IN)];
|
||||||
|
poll(&mut descriptors, Some(&Timespec::default()))?;
|
||||||
|
Ok(descriptors[0]
|
||||||
|
.revents()
|
||||||
|
.intersects(PollFlags::IN | PollFlags::HUP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ChildBroker {
|
||||||
|
exclusion: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn child_broker() -> &'static ChildBroker {
|
||||||
|
CHILD_BROKER.get_or_init(|| ChildBroker {
|
||||||
|
exclusion: Mutex::new(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildBroker {
|
||||||
|
/// Spawns the repository wrapper, stages exact ownership, accepts its acknowledgment,
|
||||||
|
/// and only then releases target execution.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses and reaps the blocked wrapper on every failed acquisition or callback.
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
pub fn acquire<S, Stage, Acknowledge>(
|
||||||
|
&self,
|
||||||
|
wrapper: &Path,
|
||||||
|
expected_parent: PidfsProcessIdentityV1,
|
||||||
|
target: &TargetCommand,
|
||||||
|
stage: Stage,
|
||||||
|
acknowledge: Acknowledge,
|
||||||
|
) -> io::Result<RetainedProcess>
|
||||||
|
where
|
||||||
|
Stage: FnOnce(PidfsProcessIdentityV1) -> io::Result<S>,
|
||||||
|
Acknowledge: FnOnce(&S, PidfsProcessIdentityV1) -> io::Result<()>,
|
||||||
|
{
|
||||||
|
let _guard = self
|
||||||
|
.exclusion
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||||
|
require_default_sigchld()?;
|
||||||
|
let (release, wait) = launch_barrier()?;
|
||||||
|
let mut command = std::process::Command::new(wrapper);
|
||||||
|
append_parent_identity(&mut command, expected_parent);
|
||||||
|
target.append_to(&mut command);
|
||||||
|
command.stdin(wait.into_stdio());
|
||||||
|
let mut child = command.spawn()?;
|
||||||
|
let host_pid = child.id();
|
||||||
|
let acquired = acquire_spawned_child(host_pid);
|
||||||
|
let process = match acquired {
|
||||||
|
Ok(process) => process,
|
||||||
|
Err(error) => return fail_before_pidfd(release, &mut child, error),
|
||||||
|
};
|
||||||
|
let staged = match stage(process.identity) {
|
||||||
|
Ok(staged) => staged,
|
||||||
|
Err(error) => return fail_after_pidfd(release, process, error),
|
||||||
|
};
|
||||||
|
if let Err(error) = acknowledge(&staged, process.identity) {
|
||||||
|
return fail_after_pidfd(release, process, error);
|
||||||
|
}
|
||||||
|
if let Err(error) = release.release() {
|
||||||
|
return fail_after_pidfd_closed(process, error);
|
||||||
|
}
|
||||||
|
drop(child);
|
||||||
|
Ok(process)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when serialization or exact pidfd reaping fails.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn reap(&self, process: RetainedProcess) -> io::Result<()> {
|
||||||
|
let _guard = self
|
||||||
|
.exclusion
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||||
|
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
||||||
|
drop(process);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
fn acquire_spawned_child(host_pid: u32) -> io::Result<RetainedProcess> {
|
||||||
|
let raw_pid = i32::try_from(host_pid)
|
||||||
|
.map_err(|_| io::Error::other("spawn returned out-of-range child PID"))?;
|
||||||
|
let pid = Pid::from_raw(raw_pid)
|
||||||
|
.ok_or_else(|| io::Error::other("spawn returned invalid child PID"))?;
|
||||||
|
require_host_pid_namespace(host_pid)?;
|
||||||
|
let pidfd = rustix::process::pidfd_open(pid, PidfdFlags::empty())?;
|
||||||
|
verify_direct_child_pidfd(pidfd.as_fd())?;
|
||||||
|
let authority = authority_from_pidfd(pidfd.as_fd(), read_boot_id()?)?;
|
||||||
|
let start_time = read_start_time(host_pid)?;
|
||||||
|
Ok(RetainedProcess {
|
||||||
|
pidfd,
|
||||||
|
identity: PidfsProcessIdentityV1::new(
|
||||||
|
authority,
|
||||||
|
HostPidLocator::new(host_pid),
|
||||||
|
ProcessStartTimeTicks::new(start_time),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses descriptors that do not name a waitable direct child of this broker process.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn verify_direct_child_pidfd(pidfd: impl AsFd) -> io::Result<()> {
|
||||||
|
rustix::process::waitid(
|
||||||
|
WaitId::PidFd(pidfd.as_fd()),
|
||||||
|
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
fn fail_before_pidfd(
|
||||||
|
release: BarrierRelease,
|
||||||
|
child: &mut std::process::Child,
|
||||||
|
error: io::Error,
|
||||||
|
) -> io::Result<RetainedProcess> {
|
||||||
|
drop(release);
|
||||||
|
child.wait()?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_after_pidfd(
|
||||||
|
release: BarrierRelease,
|
||||||
|
process: RetainedProcess,
|
||||||
|
error: io::Error,
|
||||||
|
) -> io::Result<RetainedProcess> {
|
||||||
|
drop(release);
|
||||||
|
fail_after_pidfd_closed(process, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
fn fail_after_pidfd_closed(
|
||||||
|
process: RetainedProcess,
|
||||||
|
error: io::Error,
|
||||||
|
) -> io::Result<RetainedProcess> {
|
||||||
|
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
||||||
|
drop(process);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses before PID lookup on boot mismatch and before action on every later mismatch.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn recover_process(identity: PidfsProcessIdentityV1) -> io::Result<RetainedProcess> {
|
||||||
|
recover_process_with(identity, read_boot_id, |host_pid| {
|
||||||
|
let raw_pid = i32::try_from(host_pid)
|
||||||
|
.map_err(|_| io::Error::other("stored PID locator is out of range"))?;
|
||||||
|
let pid = Pid::from_raw(raw_pid)
|
||||||
|
.ok_or_else(|| io::Error::other("stored PID locator is invalid"))?;
|
||||||
|
require_host_pid_namespace(host_pid)?;
|
||||||
|
rustix::process::pidfd_open(pid, PidfdFlags::empty()).map_err(Into::into)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_process_with<Boot, Open>(
|
||||||
|
identity: PidfsProcessIdentityV1,
|
||||||
|
boot: Boot,
|
||||||
|
open: Open,
|
||||||
|
) -> io::Result<RetainedProcess>
|
||||||
|
where
|
||||||
|
Boot: FnOnce() -> io::Result<BootId>,
|
||||||
|
Open: FnOnce(u32) -> io::Result<OwnedFd>,
|
||||||
|
{
|
||||||
|
let current_boot = boot()?;
|
||||||
|
if current_boot != identity.authority().boot_id {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"stored process authority belongs to another boot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let host_pid = identity.host_pid().get();
|
||||||
|
let pidfd = open(host_pid)?;
|
||||||
|
let observed = authority_from_pidfd(pidfd.as_fd(), current_boot)?;
|
||||||
|
if observed != identity.authority() {
|
||||||
|
return Err(io::Error::other("pidfs process authority mismatch"));
|
||||||
|
}
|
||||||
|
if read_start_time(host_pid)? != identity.start_time_ticks().get() {
|
||||||
|
return Err(io::Error::other("process start time mismatch"));
|
||||||
|
}
|
||||||
|
Ok(RetainedProcess { pidfd, identity })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses if arming fails or the exact parent changes during the recheck.
|
||||||
|
pub fn arm_parent_death_and_recheck(
|
||||||
|
signal: Signal,
|
||||||
|
expected_parent: PidfsProcessIdentityV1,
|
||||||
|
) -> io::Result<RetainedProcess> {
|
||||||
|
rustix::process::set_parent_process_death_signal(Some(signal))?;
|
||||||
|
let parent = recover_process(expected_parent)?;
|
||||||
|
let observed_parent = rustix::process::getppid()
|
||||||
|
.map(Pid::as_raw_nonzero)
|
||||||
|
.map(i32::from)
|
||||||
|
.and_then(|pid| u32::try_from(pid).ok());
|
||||||
|
if observed_parent != Some(expected_parent.host_pid().get()) {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"parent changed while arming parent-death action",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the current process cannot form a same-pidfd identity.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn current_process_identity() -> io::Result<PidfsProcessIdentityV1> {
|
||||||
|
let pid = rustix::process::getpid();
|
||||||
|
let host_pid = u32::try_from(i32::from(pid.as_raw_nonzero()))
|
||||||
|
.map_err(|_| io::Error::other("current PID is out of range"))?;
|
||||||
|
require_host_pid_namespace(host_pid)?;
|
||||||
|
let pidfd = rustix::process::pidfd_open(pid, PidfdFlags::empty())?;
|
||||||
|
let authority = authority_from_pidfd(pidfd.as_fd(), read_boot_id()?)?;
|
||||||
|
Ok(PidfsProcessIdentityV1::new(
|
||||||
|
authority,
|
||||||
|
HostPidLocator::new(host_pid),
|
||||||
|
ProcessStartTimeTicks::new(read_start_time(host_pid)?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authority_from_pidfd(pidfd: impl AsFd, boot_id: BootId) -> io::Result<PidfsProcessAuthorityV1> {
|
||||||
|
authority_from_pidfd_with(
|
||||||
|
pidfd,
|
||||||
|
boot_id,
|
||||||
|
|fd| Ok(fstatfs(fd)?.f_type as i64),
|
||||||
|
|fd| {
|
||||||
|
let stat = fstat(fd)?;
|
||||||
|
Ok((stat.st_dev, stat.st_ino))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authority_from_pidfd_with<Fs, Stat>(
|
||||||
|
pidfd: impl AsFd,
|
||||||
|
boot_id: BootId,
|
||||||
|
filesystem: Fs,
|
||||||
|
stat: Stat,
|
||||||
|
) -> io::Result<PidfsProcessAuthorityV1>
|
||||||
|
where
|
||||||
|
Fs: FnOnce(std::os::fd::BorrowedFd<'_>) -> io::Result<i64>,
|
||||||
|
Stat: FnOnce(std::os::fd::BorrowedFd<'_>) -> io::Result<(u64, u64)>,
|
||||||
|
{
|
||||||
|
require_pidfs_magic(filesystem(pidfd.as_fd())?)?;
|
||||||
|
let (device, inode) = stat(pidfd.as_fd())?;
|
||||||
|
Ok(PidfsProcessAuthorityV1 {
|
||||||
|
boot_id,
|
||||||
|
pidfs_st_dev: PidfsDeviceId::new(device),
|
||||||
|
pidfs_st_ino: PidfsInodeId::new(inode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses every filesystem magic other than `PID_FS_MAGIC`.
|
||||||
|
pub fn require_pidfs_magic(magic: i64) -> io::Result<()> {
|
||||||
|
if magic == PID_FS_MAGIC {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other("descriptor is not a pidfs process handle"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses unreadable or non-canonical boot identifiers.
|
||||||
|
pub fn read_boot_id() -> io::Result<BootId> {
|
||||||
|
parse_boot_id(&std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses every non-canonical UUID spelling.
|
||||||
|
pub fn parse_boot_id(value: &str) -> io::Result<BootId> {
|
||||||
|
let value = value.strip_suffix('\n').unwrap_or(value);
|
||||||
|
if value.len() != 36
|
||||||
|
|| value.as_bytes()[8] != b'-'
|
||||||
|
|| value.as_bytes()[13] != b'-'
|
||||||
|
|| value.as_bytes()[18] != b'-'
|
||||||
|
|| value.as_bytes()[23] != b'-'
|
||||||
|
|| value
|
||||||
|
.bytes()
|
||||||
|
.enumerate()
|
||||||
|
.any(|(index, byte)| !matches!(index, 8 | 13 | 18 | 23) && !byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return Err(io::Error::other("boot ID is not a canonical UUID"));
|
||||||
|
}
|
||||||
|
let compact = value.replace('-', "");
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&compact[index * 2..index * 2 + 2], 16)
|
||||||
|
.map_err(|_| io::Error::other("boot ID is not a canonical UUID"))?;
|
||||||
|
}
|
||||||
|
Ok(BootId::new(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses unreadable or malformed process statistics.
|
||||||
|
pub fn read_start_time(host_pid: u32) -> io::Result<u64> {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{host_pid}/stat"))?;
|
||||||
|
let command_end = stat
|
||||||
|
.rfind(") ")
|
||||||
|
.ok_or_else(|| io::Error::other("process stat has no command terminator"))?;
|
||||||
|
stat[command_end + 2..]
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(19)
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.ok_or_else(|| io::Error::other("process stat has no valid start time"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_host_pid_namespace(host_pid: u32) -> io::Result<()> {
|
||||||
|
let status = std::fs::read_to_string(format!("/proc/{host_pid}/status"))?;
|
||||||
|
let nspid = status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("NSpid:"))
|
||||||
|
.ok_or_else(|| io::Error::other("process status has no PID namespace mapping"))?;
|
||||||
|
if nspid.trim() == host_pid.to_string() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other(
|
||||||
|
"PID locator is not unambiguous in the host PID namespace",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_default_sigchld() -> io::Result<()> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status")?;
|
||||||
|
let ignored = signal_mask(&status, "SigIgn:")?;
|
||||||
|
let caught = signal_mask(&status, "SigCgt:")?;
|
||||||
|
let sigchld = 1_u128 << (17 - 1);
|
||||||
|
if ignored & sigchld == 0 && caught & sigchld == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other("SIGCHLD is not at default disposition"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_mask(status: &str, name: &str) -> io::Result<u128> {
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix(name))
|
||||||
|
.and_then(|value| u128::from_str_radix(value.trim(), 16).ok())
|
||||||
|
.ok_or_else(|| io::Error::other("process status has no valid signal mask"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the fail-closed pipe cannot be created.
|
||||||
|
pub fn launch_barrier() -> io::Result<(BarrierRelease, BarrierWait)> {
|
||||||
|
let (reader, writer) = pipe_with(PipeFlags::empty())?;
|
||||||
|
fcntl_setfd(writer.as_fd(), FdFlags::CLOEXEC)?;
|
||||||
|
Ok((
|
||||||
|
BarrierRelease(File::from(writer)),
|
||||||
|
BarrierWait(File::from(reader)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BarrierRelease(File);
|
||||||
|
|
||||||
|
impl BarrierRelease {
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error unless the complete release token is written.
|
||||||
|
pub fn release(mut self) -> io::Result<()> {
|
||||||
|
self.0.write_all(BARRIER_RELEASE)?;
|
||||||
|
self.0.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BarrierWait(File);
|
||||||
|
|
||||||
|
impl BarrierWait {
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_stdio(self) -> Stdio {
|
||||||
|
Stdio::from(self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the inherited standard-input descriptor cannot be duplicated.
|
||||||
|
pub fn from_stdin() -> io::Result<Self> {
|
||||||
|
Ok(Self(File::open("/proc/self/fd/0")?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses EOF, partial, altered, or overlong releases.
|
||||||
|
pub fn wait(mut self) -> io::Result<()> {
|
||||||
|
let mut release = [0_u8; BARRIER_RELEASE.len()];
|
||||||
|
self.0.read_exact(&mut release)?;
|
||||||
|
let mut excess = [0_u8; 1];
|
||||||
|
if release != *BARRIER_RELEASE || self.0.read(&mut excess)? != 0 {
|
||||||
|
return Err(io::Error::other("malformed launch-barrier release"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_types)]
|
||||||
|
pub fn append_parent_identity(
|
||||||
|
command: &mut std::process::Command,
|
||||||
|
identity: PidfsProcessIdentityV1,
|
||||||
|
) {
|
||||||
|
let authority = identity.authority();
|
||||||
|
command
|
||||||
|
.arg("--parent-boot")
|
||||||
|
.arg(hex(authority.boot_id.as_bytes()))
|
||||||
|
.arg("--parent-device")
|
||||||
|
.arg(authority.pidfs_st_dev.get().to_string())
|
||||||
|
.arg("--parent-inode")
|
||||||
|
.arg(authority.pidfs_st_ino.get().to_string())
|
||||||
|
.arg("--parent-pid")
|
||||||
|
.arg(identity.host_pid().get().to_string())
|
||||||
|
.arg("--parent-start")
|
||||||
|
.arg(identity.start_time_ticks().get().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses missing, reordered, malformed, or incomplete wrapper arguments.
|
||||||
|
pub fn parse_wrapper_arguments(
|
||||||
|
arguments: impl IntoIterator<Item = OsString>,
|
||||||
|
) -> io::Result<(PidfsProcessIdentityV1, TargetCommand)> {
|
||||||
|
let mut arguments = arguments.into_iter();
|
||||||
|
let boot = named_argument(&mut arguments, "--parent-boot")?;
|
||||||
|
let device = number_argument(&mut arguments, "--parent-device")?;
|
||||||
|
let inode = number_argument(&mut arguments, "--parent-inode")?;
|
||||||
|
let pid = number_argument(&mut arguments, "--parent-pid")?;
|
||||||
|
let start = number_argument(&mut arguments, "--parent-start")?;
|
||||||
|
if arguments.next().as_deref() != Some(OsStr::new("--")) {
|
||||||
|
return Err(io::Error::other("wrapper target separator is missing"));
|
||||||
|
}
|
||||||
|
let program = arguments
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| io::Error::other("wrapper target is missing"))?;
|
||||||
|
let target = TargetCommand {
|
||||||
|
program,
|
||||||
|
arguments: arguments.collect(),
|
||||||
|
};
|
||||||
|
let boot = parse_boot_hex(&boot)?;
|
||||||
|
Ok((
|
||||||
|
PidfsProcessIdentityV1::new(
|
||||||
|
PidfsProcessAuthorityV1 {
|
||||||
|
boot_id: boot,
|
||||||
|
pidfs_st_dev: PidfsDeviceId::new(device),
|
||||||
|
pidfs_st_ino: PidfsInodeId::new(inode),
|
||||||
|
},
|
||||||
|
HostPidLocator::new(
|
||||||
|
u32::try_from(pid).map_err(|_| io::Error::other("parent PID is out of range"))?,
|
||||||
|
),
|
||||||
|
ProcessStartTimeTicks::new(start),
|
||||||
|
),
|
||||||
|
target,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named_argument(
|
||||||
|
arguments: &mut impl Iterator<Item = OsString>,
|
||||||
|
name: &str,
|
||||||
|
) -> io::Result<OsString> {
|
||||||
|
if arguments.next().as_deref() != Some(OsStr::new(name)) {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"wrapper argument {name} is missing"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
arguments
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| io::Error::other(format!("wrapper argument {name} has no value")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_argument(arguments: &mut impl Iterator<Item = OsString>, name: &str) -> io::Result<u64> {
|
||||||
|
named_argument(arguments, name)?
|
||||||
|
.to_str()
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.ok_or_else(|| io::Error::other(format!("wrapper argument {name} is invalid")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_boot_hex(value: &OsStr) -> io::Result<BootId> {
|
||||||
|
let value = value
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| io::Error::other("parent boot ID is not UTF-8"))?;
|
||||||
|
if value.len() != 32 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
return Err(io::Error::other("parent boot ID is invalid"));
|
||||||
|
}
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
|
||||||
|
.map_err(|_| io::Error::other("parent boot ID is invalid"))?;
|
||||||
|
}
|
||||||
|
Ok(BootId::new(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||||
|
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||||
|
output
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn exec_target(target: TargetCommand) -> io::Error {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
std::process::Command::new(target.program)
|
||||||
|
.args(target.arguments)
|
||||||
|
.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broker_is_process_global() {
|
||||||
|
assert!(std::ptr::eq(child_broker(), child_broker()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn barrier_reader_is_inheritable_and_writer_is_close_on_exec() {
|
||||||
|
let (release, wait) = launch_barrier().unwrap();
|
||||||
|
let reader_flags = rustix::io::fcntl_getfd(wait.0.as_fd()).unwrap();
|
||||||
|
let writer_flags = rustix::io::fcntl_getfd(release.0.as_fd()).unwrap();
|
||||||
|
assert!(!reader_flags.contains(FdFlags::CLOEXEC));
|
||||||
|
assert!(writer_flags.contains(FdFlags::CLOEXEC));
|
||||||
|
assert_ne!(wait.0.as_raw_fd(), release.0.as_raw_fd());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boot_id_requires_exact_canonical_uuid_syntax() {
|
||||||
|
parse_boot_id("00112233-4455-6677-8899-aabbccddeeff\n").unwrap();
|
||||||
|
for invalid in [
|
||||||
|
"00112233445566778899aabbccddeeff",
|
||||||
|
"00112233_4455-6677-8899-aabbccddeeff",
|
||||||
|
"00112233-4455-6677-8899-aabbccddeeff-extra",
|
||||||
|
"00112233-4455-6677-8899-aabbccddeefg",
|
||||||
|
"00112233-4455-6677-8899-aabbccddeeff\n\n",
|
||||||
|
] {
|
||||||
|
assert!(parse_boot_id(invalid).is_err(), "accepted {invalid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_checks_boot_before_pid_lookup() {
|
||||||
|
let identity = PidfsProcessIdentityV1::new(
|
||||||
|
PidfsProcessAuthorityV1 {
|
||||||
|
boot_id: BootId::new([1; 16]),
|
||||||
|
pidfs_st_dev: PidfsDeviceId::new(1),
|
||||||
|
pidfs_st_ino: PidfsInodeId::new(1),
|
||||||
|
},
|
||||||
|
HostPidLocator::new(1),
|
||||||
|
ProcessStartTimeTicks::new(1),
|
||||||
|
);
|
||||||
|
let opened = Cell::new(false);
|
||||||
|
let result = recover_process_with(
|
||||||
|
identity,
|
||||||
|
|| Ok(BootId::new([2; 16])),
|
||||||
|
|_| {
|
||||||
|
opened.set(true);
|
||||||
|
unreachable!()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!opened.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pidfs_magic_precedes_same_descriptor_stat_and_errors_refuse() {
|
||||||
|
let file = File::open("/dev/null").unwrap();
|
||||||
|
let raw = file.as_raw_fd();
|
||||||
|
let events = Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||||
|
let filesystem_events = Rc::clone(&events);
|
||||||
|
let stat_events = Rc::clone(&events);
|
||||||
|
let authority = authority_from_pidfd_with(
|
||||||
|
file.as_fd(),
|
||||||
|
BootId::new([1; 16]),
|
||||||
|
move |fd| {
|
||||||
|
filesystem_events
|
||||||
|
.borrow_mut()
|
||||||
|
.push(("fstatfs", fd.as_raw_fd()));
|
||||||
|
Ok(PID_FS_MAGIC)
|
||||||
|
},
|
||||||
|
move |fd| {
|
||||||
|
stat_events.borrow_mut().push(("fstat", fd.as_raw_fd()));
|
||||||
|
Ok((7, 9))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&*events.borrow(), &[("fstatfs", raw), ("fstat", raw)]);
|
||||||
|
assert_eq!(authority.pidfs_st_dev.get(), 7);
|
||||||
|
assert_eq!(authority.pidfs_st_ino.get(), 9);
|
||||||
|
|
||||||
|
for filesystem_error in [false, true] {
|
||||||
|
let stat_called = Cell::new(false);
|
||||||
|
let result = authority_from_pidfd_with(
|
||||||
|
file.as_fd(),
|
||||||
|
BootId::new([1; 16]),
|
||||||
|
|_| {
|
||||||
|
if filesystem_error {
|
||||||
|
Err(io::Error::other("injected fstatfs error"))
|
||||||
|
} else {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_| {
|
||||||
|
stat_called.set(true);
|
||||||
|
Ok((1, 1))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!stat_called.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
authority_from_pidfd_with(
|
||||||
|
file.as_fd(),
|
||||||
|
BootId::new([1; 16]),
|
||||||
|
|_| Ok(PID_FS_MAGIC),
|
||||||
|
|_| Err(io::Error::other("injected fstat error")),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
crates/lazaret-linux/tests/acquisition.rs
Normal file
181
crates/lazaret-linux/tests/acquisition.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Seek, Write};
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use lazaret_linux::process::{
|
||||||
|
TargetCommand, append_parent_identity, child_broker, current_process_identity,
|
||||||
|
verify_direct_child_pidfd,
|
||||||
|
};
|
||||||
|
use rustix::process::{PidfdFlags, pidfd_open};
|
||||||
|
|
||||||
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn marker(name: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"lazaret-acquisition-{name}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(path: &std::path::Path) -> TargetCommand {
|
||||||
|
TargetCommand::new("/bin/sh")
|
||||||
|
.argument("-c")
|
||||||
|
.argument(format!("printf target > '{}'", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrapper() -> &'static std::path::Path {
|
||||||
|
std::path::Path::new(env!("CARGO_BIN_EXE_lazaret-launch-wrapper"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_runs_only_after_exact_stage_ack_and_release() {
|
||||||
|
let marker = marker("released");
|
||||||
|
let parent = current_process_identity().unwrap();
|
||||||
|
let process = child_broker()
|
||||||
|
.acquire(
|
||||||
|
wrapper(),
|
||||||
|
parent,
|
||||||
|
&target(&marker),
|
||||||
|
|identity| {
|
||||||
|
assert!(!marker.exists());
|
||||||
|
Ok(identity)
|
||||||
|
},
|
||||||
|
|staged, identity| {
|
||||||
|
assert_eq!(*staged, identity);
|
||||||
|
assert!(!marker.exists());
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..100 {
|
||||||
|
if marker.exists() {
|
||||||
|
child_broker().reap(process).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&marker).unwrap(), b"target");
|
||||||
|
std::fs::remove_file(marker).unwrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
panic!("released target did not run");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_and_ack_failures_leave_target_absent() {
|
||||||
|
for failure in ["stage", "ack"] {
|
||||||
|
let marker = marker(failure);
|
||||||
|
let parent = current_process_identity().unwrap();
|
||||||
|
let result = child_broker().acquire(
|
||||||
|
wrapper(),
|
||||||
|
parent,
|
||||||
|
&target(&marker),
|
||||||
|
|_| {
|
||||||
|
if failure == "stage" {
|
||||||
|
Err(std::io::Error::other("injected stage failure"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|(), _| {
|
||||||
|
if failure == "ack" {
|
||||||
|
Err(std::io::Error::other("injected acknowledgment failure"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!marker.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
fn inherited_barrier_refuses_eof_partial_and_malformed_release() {
|
||||||
|
for bytes in [
|
||||||
|
Vec::new(),
|
||||||
|
b"partial".to_vec(),
|
||||||
|
vec![b'x'; 32],
|
||||||
|
b"LAZARET-CAUSAL-BARRIER-RELEASE!!x".to_vec(),
|
||||||
|
] {
|
||||||
|
let marker = marker("barrier-refusal");
|
||||||
|
let parent = current_process_identity().unwrap();
|
||||||
|
let mut command = std::process::Command::new(wrapper());
|
||||||
|
append_parent_identity(&mut command, parent);
|
||||||
|
command
|
||||||
|
.arg("--")
|
||||||
|
.arg("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!("printf target > '{}'", marker.display()));
|
||||||
|
let mut input = tempfile_file(&bytes);
|
||||||
|
input.flush().unwrap();
|
||||||
|
command.stdin(Stdio::from(input));
|
||||||
|
assert!(!command.status().unwrap().success());
|
||||||
|
assert!(!marker.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempfile_file(bytes: &[u8]) -> File {
|
||||||
|
let path = marker("barrier-input");
|
||||||
|
let mut file = File::options()
|
||||||
|
.create_new(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
file.write_all(bytes).unwrap();
|
||||||
|
file.rewind().unwrap();
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
fn foreign_non_child_pidfd_is_refused_without_action() {
|
||||||
|
let pidfd = pidfd_open(rustix::process::getpid(), PidfdFlags::empty()).unwrap();
|
||||||
|
assert!(verify_direct_child_pidfd(pidfd.as_fd()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_global_broker_serializes_concurrent_acquisitions() {
|
||||||
|
let start = Arc::new(Barrier::new(9));
|
||||||
|
let inside = Arc::new(AtomicU64::new(0));
|
||||||
|
let maximum = Arc::new(AtomicU64::new(0));
|
||||||
|
let workers = (0..8)
|
||||||
|
.map(|_| {
|
||||||
|
let start = Arc::clone(&start);
|
||||||
|
let inside = Arc::clone(&inside);
|
||||||
|
let maximum = Arc::clone(&maximum);
|
||||||
|
thread::spawn(move || {
|
||||||
|
start.wait();
|
||||||
|
let parent = current_process_identity().unwrap();
|
||||||
|
let process = child_broker()
|
||||||
|
.acquire(
|
||||||
|
wrapper(),
|
||||||
|
parent,
|
||||||
|
&TargetCommand::new("/bin/true"),
|
||||||
|
|_| {
|
||||||
|
let active = inside.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
maximum.fetch_max(active, Ordering::SeqCst);
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
inside.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
|(), _| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
child_broker().reap(process).unwrap();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
start.wait();
|
||||||
|
for worker in workers {
|
||||||
|
worker.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(maximum.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
54
crates/lazaret-linux/tests/capability-drop-helper.rs
Normal file
54
crates/lazaret-linux/tests/capability-drop-helper.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use rustix::thread::{
|
||||||
|
CapabilitySet, capabilities, capability_is_in_ambient_set, configure_capability_in_ambient_set,
|
||||||
|
set_capabilities,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
if std::env::var_os("LAZARET_CAPABILITY_DROP_ISOLATED").is_none() {
|
||||||
|
let before = lazaret_linux::capability::inspect(CapabilitySet::NET_ADMIN)
|
||||||
|
.expect("inspect unprivileged capability state");
|
||||||
|
if lazaret_linux::capability::irreversibly_drop(CapabilitySet::NET_ADMIN).is_ok()
|
||||||
|
|| lazaret_linux::capability::inspect(CapabilitySet::NET_ADMIN).ok() != Some(before)
|
||||||
|
{
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
return match std::process::Command::new("unshare")
|
||||||
|
.args(["--user", "--map-root-user"])
|
||||||
|
.arg(std::env::current_exe().expect("test executable path"))
|
||||||
|
.env("LAZARET_CAPABILITY_DROP_ISOLATED", "1")
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(status) if status.success() => ExitCode::SUCCESS,
|
||||||
|
Ok(_) | Err(_) => ExitCode::FAILURE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let capability = CapabilitySet::NET_ADMIN;
|
||||||
|
let retained = CapabilitySet::CHOWN;
|
||||||
|
let mut sets = capabilities(None).expect("inspect isolated capability sets");
|
||||||
|
sets.inheritable.insert(retained);
|
||||||
|
set_capabilities(None, sets).expect("prepare unrelated ambient capability");
|
||||||
|
configure_capability_in_ambient_set(retained, true)
|
||||||
|
.expect("raise unrelated ambient capability");
|
||||||
|
if lazaret_linux::capability::irreversibly_drop(capability).is_err() {
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let Ok(observed) = lazaret_linux::capability::inspect(capability) else {
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
};
|
||||||
|
if observed.sets.effective.intersects(capability)
|
||||||
|
|| observed.sets.permitted.intersects(capability)
|
||||||
|
|| observed.sets.inheritable.intersects(capability)
|
||||||
|
|| observed.ambient
|
||||||
|
|| observed.bounding
|
||||||
|
|| !capability_is_in_ambient_set(retained).unwrap_or(false)
|
||||||
|
|| configure_capability_in_ambient_set(capability, true).is_ok()
|
||||||
|
{
|
||||||
|
ExitCode::FAILURE
|
||||||
|
} else {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,41 @@ class VerificationTests(unittest.TestCase):
|
||||||
self.assertIsNotNone(VERIFY.LINT_BYPASS.search(source))
|
self.assertIsNotNone(VERIFY.LINT_BYPASS.search(source))
|
||||||
self.assertIsNone(VERIFY.LINT_BYPASS.search("#[allow(dead_code)]"))
|
self.assertIsNone(VERIFY.LINT_BYPASS.search("#[allow(dead_code)]"))
|
||||||
|
|
||||||
|
def test_process_lint_allowances_are_exact_reviewed_functions(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
VERIFY.PROCESS_LINT_ALLOWANCES["crates/lazaret-linux/tests/capability-drop-helper.rs"],
|
||||||
|
{"main"},
|
||||||
|
)
|
||||||
|
self.assertNotIn("spawn_unreviewed", set().union(*VERIFY.PROCESS_LINT_ALLOWANCES.values()))
|
||||||
|
|
||||||
|
def test_process_lint_allowance_parser_rejects_false_exemptions(self) -> None:
|
||||||
|
with self.assertRaisesRegex(VERIFY.VerificationError, "whole-file"):
|
||||||
|
VERIFY.process_lint_allowances(
|
||||||
|
"example.rs", "#![allow(clippy::disallowed_methods)]\nfn reviewed() {}"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(VERIFY.VerificationError, "non-function"):
|
||||||
|
VERIFY.process_lint_allowances(
|
||||||
|
"example.rs",
|
||||||
|
"#[allow(clippy::disallowed_methods)]\nstruct Boundary;\nfn hidden() {}",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
VERIFY.process_lint_allowances(
|
||||||
|
"example.rs", "#[allow(clippy::disallowed_methods)]\nfn arbitrary() {}"
|
||||||
|
),
|
||||||
|
{"arbitrary"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_license_audit_accepts_permitted_choice_only(self) -> None:
|
||||||
|
allowed = {"Apache-2.0", "MIT"}
|
||||||
|
self.assertTrue(
|
||||||
|
VERIFY.license_permitted(
|
||||||
|
"Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT", allowed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(VERIFY.license_permitted("GPL-3.0-only OR MIT", allowed))
|
||||||
|
self.assertFalse(VERIFY.license_permitted("GPL-3.0-only", allowed))
|
||||||
|
self.assertFalse(VERIFY.license_permitted("MIT AND GPL-3.0-only", allowed))
|
||||||
|
|
||||||
def test_workspace_source_discovery_is_not_confined_to_crates(self) -> None:
|
def test_workspace_source_discovery_is_not_confined_to_crates(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
|
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
|
||||||
package_root = Path(directory)
|
package_root = Path(directory)
|
||||||
|
|
@ -125,6 +160,14 @@ class VerificationTests(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(VERIFY.VerificationError, "execve surface count 2"):
|
with self.assertRaisesRegex(VERIFY.VerificationError, "execve surface count 2"):
|
||||||
VERIFY.validate_process_trace("example", ["execve(a)", "execve(b)"])
|
VERIFY.validate_process_trace("example", ["execve(a)", "execve(b)"])
|
||||||
|
|
||||||
|
def test_wrapper_trace_requires_its_exec_in_place(self) -> None:
|
||||||
|
VERIFY.validate_process_trace(
|
||||||
|
"lazaret-launch-wrapper",
|
||||||
|
["execve(wrapper)", "execve(/bin/true)"],
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(VERIFY.VerificationError, "execve surface count 1"):
|
||||||
|
VERIFY.validate_process_trace("lazaret-launch-wrapper", ["execve(wrapper)"])
|
||||||
|
|
||||||
def test_process_trace_rejects_each_forbidden_surface(self) -> None:
|
def test_process_trace_rejects_each_forbidden_surface(self) -> None:
|
||||||
forbidden = (
|
forbidden = (
|
||||||
"clone(child_stack=NULL) = 1",
|
"clone(child_stack=NULL) = 1",
|
||||||
|
|
@ -157,6 +200,7 @@ class VerificationTests(unittest.TestCase):
|
||||||
"lazaret",
|
"lazaret",
|
||||||
"lazaret-escalation-client",
|
"lazaret-escalation-client",
|
||||||
"lazaret-host-coordinator",
|
"lazaret-host-coordinator",
|
||||||
|
"lazaret-launch-wrapper",
|
||||||
"lazaret-network-sidecar",
|
"lazaret-network-sidecar",
|
||||||
"lazaret-runtime-supervisor",
|
"lazaret-runtime-supervisor",
|
||||||
},
|
},
|
||||||
|
|
@ -224,6 +268,18 @@ class VerificationTests(unittest.TestCase):
|
||||||
"--workspace",
|
"--workspace",
|
||||||
"--all-features",
|
"--all-features",
|
||||||
"--locked",
|
"--locked",
|
||||||
|
"--filterset",
|
||||||
|
"not binary(capability-drop-helper)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"cargo",
|
||||||
|
"test",
|
||||||
|
"--offline",
|
||||||
|
"-p",
|
||||||
|
"lazaret-linux",
|
||||||
|
"--test",
|
||||||
|
"capability-drop-helper",
|
||||||
|
"--locked",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"cargo",
|
"cargo",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ NEXTEST_COMMAND = (
|
||||||
"--workspace",
|
"--workspace",
|
||||||
"--all-features",
|
"--all-features",
|
||||||
"--locked",
|
"--locked",
|
||||||
|
"--filterset",
|
||||||
|
"not binary(capability-drop-helper)",
|
||||||
)
|
)
|
||||||
DOCTEST_COMMAND = (
|
DOCTEST_COMMAND = (
|
||||||
"cargo",
|
"cargo",
|
||||||
|
|
@ -36,6 +38,16 @@ DOCTEST_COMMAND = (
|
||||||
"--locked",
|
"--locked",
|
||||||
"--doc",
|
"--doc",
|
||||||
)
|
)
|
||||||
|
CAPABILITY_TEST_COMMAND = (
|
||||||
|
"cargo",
|
||||||
|
"test",
|
||||||
|
"--offline",
|
||||||
|
"-p",
|
||||||
|
"lazaret-linux",
|
||||||
|
"--test",
|
||||||
|
"capability-drop-helper",
|
||||||
|
"--locked",
|
||||||
|
)
|
||||||
REQUIRED_TOOL_VERSIONS = (
|
REQUIRED_TOOL_VERSIONS = (
|
||||||
("rustc", ("--version",), (0,), re.compile(r"^rustc 1\.90\.0 ")),
|
("rustc", ("--version",), (0,), re.compile(r"^rustc 1\.90\.0 ")),
|
||||||
("cargo", ("--version",), (0,), re.compile(r"^cargo 1\.90\.0 ")),
|
("cargo", ("--version",), (0,), re.compile(r"^cargo 1\.90\.0 ")),
|
||||||
|
|
@ -90,7 +102,27 @@ REQUIRED_DISALLOWED_METHODS = {
|
||||||
"std::process::Command::spawn",
|
"std::process::Command::spawn",
|
||||||
"std::process::Command::status",
|
"std::process::Command::status",
|
||||||
}
|
}
|
||||||
PROCESS_LINT_EXEMPTIONS = frozenset()
|
PROCESS_LINT_ALLOWANCES = {
|
||||||
|
"crates/lazaret-linux/src/process.rs": {
|
||||||
|
"append_to",
|
||||||
|
"send_signal",
|
||||||
|
"acquire",
|
||||||
|
"reap",
|
||||||
|
"acquire_spawned_child",
|
||||||
|
"verify_direct_child_pidfd",
|
||||||
|
"fail_before_pidfd",
|
||||||
|
"fail_after_pidfd_closed",
|
||||||
|
"recover_process",
|
||||||
|
"current_process_identity",
|
||||||
|
"append_parent_identity",
|
||||||
|
"exec_target",
|
||||||
|
},
|
||||||
|
"crates/lazaret-linux/tests/acquisition.rs": {
|
||||||
|
"inherited_barrier_refuses_eof_partial_and_malformed_release",
|
||||||
|
"foreign_non_child_pidfd_is_refused_without_action",
|
||||||
|
},
|
||||||
|
"crates/lazaret-linux/tests/capability-drop-helper.rs": {"main"},
|
||||||
|
}
|
||||||
FORBIDDEN_PROCESS_SYSCALLS = tuple(
|
FORBIDDEN_PROCESS_SYSCALLS = tuple(
|
||||||
re.compile(pattern)
|
re.compile(pattern)
|
||||||
for pattern in (
|
for pattern in (
|
||||||
|
|
@ -203,16 +235,37 @@ def audit_workspace_lints(metadata: dict) -> None:
|
||||||
f"{package['name']}: package must inherit workspace lints without override",
|
f"{package['name']}: package must inherit workspace lints without override",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
observed_allowances = {}
|
||||||
for path in workspace_rust_sources(metadata):
|
for path in workspace_rust_sources(metadata):
|
||||||
relative = str(path.relative_to(ROOT))
|
relative = str(path.relative_to(ROOT))
|
||||||
|
source = path.read_text(errors="replace")
|
||||||
|
functions = process_lint_allowances(relative, source)
|
||||||
|
if functions:
|
||||||
|
observed_allowances[relative] = functions
|
||||||
require(
|
require(
|
||||||
LINT_BYPASS.search(path.read_text(errors="replace")) is None
|
observed_allowances == PROCESS_LINT_ALLOWANCES,
|
||||||
or relative in PROCESS_LINT_EXEMPTIONS,
|
"process-safety lint allowances must match exact reviewed functions",
|
||||||
f"process-safety lint exemption is forbidden: {relative}",
|
|
||||||
)
|
)
|
||||||
print("PASS workspace unsafe and process-control lint boundary")
|
print("PASS workspace unsafe and process-control lint boundary")
|
||||||
|
|
||||||
|
|
||||||
|
def process_lint_allowances(relative: str, source: str) -> set[str]:
|
||||||
|
functions = set()
|
||||||
|
for exemption in LINT_BYPASS.finditer(source):
|
||||||
|
require(
|
||||||
|
re.match(r"#\s*!", exemption.group()) is None,
|
||||||
|
f"whole-file process lint exemption: {relative}",
|
||||||
|
)
|
||||||
|
following = source[exemption.end() : exemption.end() + 400]
|
||||||
|
function = re.match(
|
||||||
|
r"\s*\)\s*\]\s*(?:#\[[^]]+\]\s*)*(?:pub\s+)?fn\s+([A-Za-z0-9_]+)\s*[<(]",
|
||||||
|
following,
|
||||||
|
)
|
||||||
|
require(function is not None, f"non-function process lint exemption: {relative}")
|
||||||
|
functions.add(function.group(1))
|
||||||
|
return functions
|
||||||
|
|
||||||
|
|
||||||
def workspace_rust_sources(metadata: dict) -> list[Path]:
|
def workspace_rust_sources(metadata: dict) -> list[Path]:
|
||||||
packages = {package["id"]: package for package in metadata["packages"]}
|
packages = {package["id"]: package for package in metadata["packages"]}
|
||||||
target_directory = Path(metadata["target_directory"]).resolve()
|
target_directory = Path(metadata["target_directory"]).resolve()
|
||||||
|
|
@ -247,13 +300,8 @@ def audit_dependencies(metadata: dict) -> None:
|
||||||
f"unapproved dependency source: {package['name']}",
|
f"unapproved dependency source: {package['name']}",
|
||||||
)
|
)
|
||||||
license_expression = package.get("license") or ""
|
license_expression = package.get("license") or ""
|
||||||
licenses = set(re.findall(r"[A-Za-z0-9.-]+", license_expression)) - {
|
|
||||||
"AND",
|
|
||||||
"OR",
|
|
||||||
"WITH",
|
|
||||||
}
|
|
||||||
require(
|
require(
|
||||||
bool(licenses) and licenses <= allowed_licenses,
|
license_permitted(license_expression, allowed_licenses),
|
||||||
f"unapproved dependency license: {package['name']} {license_expression}",
|
f"unapproved dependency license: {package['name']} {license_expression}",
|
||||||
)
|
)
|
||||||
for dependency in package["dependencies"]:
|
for dependency in package["dependencies"]:
|
||||||
|
|
@ -280,6 +328,16 @@ def audit_dependencies(metadata: dict) -> None:
|
||||||
print("PASS dependency source, wildcard, and license audit")
|
print("PASS dependency source, wildcard, and license audit")
|
||||||
|
|
||||||
|
|
||||||
|
def license_permitted(expression: str, allowed: set[str]) -> bool:
|
||||||
|
return bool(expression) and any(
|
||||||
|
all(
|
||||||
|
term.strip(" ()") in allowed
|
||||||
|
for term in re.split(r"\s+AND\s+", alternative)
|
||||||
|
)
|
||||||
|
for alternative in re.split(r"\s+OR\s+", expression)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_models() -> None:
|
def run_models() -> None:
|
||||||
tlc = shutil.which("tlc")
|
tlc = shutil.which("tlc")
|
||||||
require(tlc is not None, "TLC must be installed on PATH")
|
require(tlc is not None, "TLC must be installed on PATH")
|
||||||
|
|
@ -362,7 +420,11 @@ def build_workspace_binaries(metadata: dict) -> dict[str, Path]:
|
||||||
|
|
||||||
def validate_process_trace(binary: str, lines: list[str]) -> None:
|
def validate_process_trace(binary: str, lines: list[str]) -> None:
|
||||||
execve_count = sum(line.startswith("execve(") for line in lines)
|
execve_count = sum(line.startswith("execve(") for line in lines)
|
||||||
require(execve_count == 1, f"{binary}: unexpected execve surface count {execve_count}")
|
expected_execve = 2 if binary == "lazaret-launch-wrapper" else 1
|
||||||
|
require(
|
||||||
|
execve_count == expected_execve,
|
||||||
|
f"{binary}: unexpected execve surface count {execve_count}",
|
||||||
|
)
|
||||||
for line in lines:
|
for line in lines:
|
||||||
matches = [pattern.pattern for pattern in FORBIDDEN_PROCESS_SYSCALLS if pattern.search(line)]
|
matches = [pattern.pattern for pattern in FORBIDDEN_PROCESS_SYSCALLS if pattern.search(line)]
|
||||||
require(not matches, f"{binary}: forbidden process syscall surface {line}: {matches}")
|
require(not matches, f"{binary}: forbidden process syscall surface {line}: {matches}")
|
||||||
|
|
@ -377,8 +439,7 @@ def audit_process_syscalls(metadata: dict) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="lazaret-process-syscalls-") as directory:
|
with tempfile.TemporaryDirectory(prefix="lazaret-process-syscalls-") as directory:
|
||||||
for binary, executable in sorted(binaries.items()):
|
for binary, executable in sorted(binaries.items()):
|
||||||
trace_base = Path(directory) / binary
|
trace_base = Path(directory) / binary
|
||||||
result = subprocess.run(
|
command = [
|
||||||
[
|
|
||||||
strace,
|
strace,
|
||||||
"-ff",
|
"-ff",
|
||||||
"-qq",
|
"-qq",
|
||||||
|
|
@ -388,7 +449,11 @@ def audit_process_syscalls(metadata: dict) -> None:
|
||||||
str(trace_base),
|
str(trace_base),
|
||||||
"--",
|
"--",
|
||||||
str(executable),
|
str(executable),
|
||||||
],
|
]
|
||||||
|
if binary == "lazaret-launch-wrapper":
|
||||||
|
command.append("--trace-self-test")
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
cwd=ROOT,
|
cwd=ROOT,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
@ -415,6 +480,7 @@ def refresh_advisories() -> None:
|
||||||
|
|
||||||
def run_rust_tests() -> None:
|
def run_rust_tests() -> None:
|
||||||
run(list(NEXTEST_COMMAND))
|
run(list(NEXTEST_COMMAND))
|
||||||
|
run(list(CAPABILITY_TEST_COMMAND))
|
||||||
run(list(DOCTEST_COMMAND))
|
run(list(DOCTEST_COMMAND))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue