Enforce causal child acquisition boundaries
This commit is contained in:
parent
1468f6b841
commit
6f722ce079
4 changed files with 603 additions and 146 deletions
|
|
@ -1,20 +1,56 @@
|
|||
use std::ffi::OsString;
|
||||
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::process::{ExitCode, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use lazaret_core::identity::{
|
||||
CoordinatorId, DomainId, GenerationId, HostPidLocator, PidfsProcessIdentityV1,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_linux::process::{
|
||||
TargetCommand, append_parent_identity, child_broker, current_process_identity,
|
||||
verify_direct_child_pidfd,
|
||||
ProcessOwnershipContext, TargetCommand, append_parent_identity, child_broker,
|
||||
current_process_identity, dispatch_private_process_role, verify_direct_child_pidfd,
|
||||
};
|
||||
use rustix::process::{PidfdFlags, pidfd_open};
|
||||
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if let Some(result) = dispatch_private_process_role(&arguments) {
|
||||
return exit(result);
|
||||
}
|
||||
if arguments.first() == Some(&OsString::from("--run-acquisition")) {
|
||||
return exit(run_acquisition(&arguments[1..]));
|
||||
}
|
||||
exit(run_all())
|
||||
}
|
||||
|
||||
fn exit(result: std::io::Result<()>) -> ExitCode {
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("acquisition fixture failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_all() -> std::io::Result<()> {
|
||||
successful_acquisition()?;
|
||||
failed_stages_and_acknowledgments()?;
|
||||
production_wrapper_refuses_former_harness_path()?;
|
||||
malformed_barriers_refuse()?;
|
||||
sa_nocldwait_refuses()?;
|
||||
foreign_non_child_refuses()?;
|
||||
concurrent_acquisitions_serialize()
|
||||
}
|
||||
|
||||
fn marker(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"lazaret-acquisition-{name}-{}-{}",
|
||||
|
|
@ -23,80 +59,177 @@ fn marker(name: &str) -> std::path::PathBuf {
|
|||
))
|
||||
}
|
||||
|
||||
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 {
|
||||
fn production_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());
|
||||
fn context() -> ProcessOwnershipContext {
|
||||
ProcessOwnershipContext {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(2),
|
||||
coordinator_id: CoordinatorId::new([3; 32]),
|
||||
slot: HostProcessOwnershipSlot::SandboxRuntimeSupervisor,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn inherited_barrier_refuses_eof_partial_and_malformed_release() {
|
||||
fn run_acquisition(arguments: &[OsString]) -> std::io::Result<()> {
|
||||
let scenario = arguments
|
||||
.first()
|
||||
.and_then(|argument| argument.to_str())
|
||||
.ok_or_else(|| std::io::Error::other("scenario is missing"))?;
|
||||
let marker = arguments
|
||||
.get(1)
|
||||
.ok_or_else(|| std::io::Error::other("marker is missing"))?;
|
||||
let parent = current_process_identity()?;
|
||||
let target = TargetCommand::new("/usr/bin/touch").argument(marker);
|
||||
let expected = std::cell::Cell::new(None);
|
||||
let stage_count = std::cell::Cell::new(0_u8);
|
||||
let acknowledgment_count = std::cell::Cell::new(0_u8);
|
||||
let process = child_broker().acquire(
|
||||
parent,
|
||||
context(),
|
||||
&target,
|
||||
|staged| {
|
||||
stage_count.set(stage_count.get() + 1);
|
||||
if scenario == "stage-fail" {
|
||||
return Err(std::io::Error::other("injected stage failure"));
|
||||
}
|
||||
expected.set(Some(staged));
|
||||
Ok(())
|
||||
},
|
||||
|staged| {
|
||||
acknowledgment_count.set(acknowledgment_count.get() + 1);
|
||||
if scenario == "ack-loss" {
|
||||
return Err(std::io::Error::other("injected acknowledgment loss"));
|
||||
}
|
||||
let committed = mismatched_acknowledgment(scenario, staged);
|
||||
if expected.get() != Some(staged) {
|
||||
return Err(std::io::Error::other("staged ownership changed"));
|
||||
}
|
||||
Ok(ProcessOwnershipCommitAck::v1(committed))
|
||||
},
|
||||
)?;
|
||||
if stage_count.get() != 1 || acknowledgment_count.get() != 1 {
|
||||
return Err(std::io::Error::other(
|
||||
"stage and acknowledgment were not consumed exactly once",
|
||||
));
|
||||
}
|
||||
child_broker().reap(process)
|
||||
}
|
||||
|
||||
fn mismatched_acknowledgment(
|
||||
scenario: &str,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> StagedProcessOwnership {
|
||||
match scenario {
|
||||
"ack-domain-mismatch" => StagedProcessOwnership::v1(
|
||||
DomainId::new([9; 32]),
|
||||
staged.generation_id(),
|
||||
staged.coordinator_id(),
|
||||
staged.slot(),
|
||||
staged.identity(),
|
||||
),
|
||||
"ack-generation-mismatch" => StagedProcessOwnership::v1(
|
||||
staged.domain_id(),
|
||||
GenerationId::new(staged.generation_id().get() + 1),
|
||||
staged.coordinator_id(),
|
||||
staged.slot(),
|
||||
staged.identity(),
|
||||
),
|
||||
"ack-coordinator-mismatch" => StagedProcessOwnership::v1(
|
||||
staged.domain_id(),
|
||||
staged.generation_id(),
|
||||
CoordinatorId::new([9; 32]),
|
||||
staged.slot(),
|
||||
staged.identity(),
|
||||
),
|
||||
"ack-slot-mismatch" => StagedProcessOwnership::v1(
|
||||
staged.domain_id(),
|
||||
staged.generation_id(),
|
||||
staged.coordinator_id(),
|
||||
HostProcessOwnershipSlot::SandboxAttachedRunsc,
|
||||
staged.identity(),
|
||||
),
|
||||
"ack-identity-mismatch" => {
|
||||
let identity = staged.identity();
|
||||
StagedProcessOwnership::v1(
|
||||
staged.domain_id(),
|
||||
staged.generation_id(),
|
||||
staged.coordinator_id(),
|
||||
staged.slot(),
|
||||
PidfsProcessIdentityV1::new(
|
||||
identity.authority(),
|
||||
HostPidLocator::new(identity.host_pid().get() + 1),
|
||||
identity.start_time_ticks(),
|
||||
),
|
||||
)
|
||||
}
|
||||
_ => staged,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn successful_acquisition() -> std::io::Result<()> {
|
||||
let marker = marker("success");
|
||||
let status = std::process::Command::new(std::env::current_exe()?)
|
||||
.arg("--run-acquisition")
|
||||
.arg("success")
|
||||
.arg(&marker)
|
||||
.status()?;
|
||||
if !status.success() || !std::fs::read(&marker)?.is_empty() {
|
||||
return Err(std::io::Error::other(
|
||||
"successful target did not run exactly once",
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(marker)
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn failed_stages_and_acknowledgments() -> std::io::Result<()> {
|
||||
for scenario in [
|
||||
"stage-fail",
|
||||
"ack-loss",
|
||||
"ack-domain-mismatch",
|
||||
"ack-generation-mismatch",
|
||||
"ack-coordinator-mismatch",
|
||||
"ack-slot-mismatch",
|
||||
"ack-identity-mismatch",
|
||||
] {
|
||||
let marker = marker(scenario);
|
||||
let status = std::process::Command::new(std::env::current_exe()?)
|
||||
.arg("--run-acquisition")
|
||||
.arg(scenario)
|
||||
.arg(&marker)
|
||||
.status()?;
|
||||
if status.success() || marker.exists() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"{scenario} released target execution"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn production_wrapper_refuses_former_harness_path() -> std::io::Result<()> {
|
||||
let marker = marker("former-harness");
|
||||
let status = std::process::Command::new(production_wrapper())
|
||||
.arg("--acquisition-harness")
|
||||
.arg("success")
|
||||
.arg(&marker)
|
||||
.status()?;
|
||||
if status.success() || marker.exists() {
|
||||
Err(std::io::Error::other(
|
||||
"production wrapper accepted former test harness path",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn malformed_barriers_refuse() -> std::io::Result<()> {
|
||||
for bytes in [
|
||||
Vec::new(),
|
||||
b"partial".to_vec(),
|
||||
|
|
@ -104,78 +237,115 @@ fn inherited_barrier_refuses_eof_partial_and_malformed_release() {
|
|||
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);
|
||||
let parent = current_process_identity()?;
|
||||
let mut command = std::process::Command::new(production_wrapper());
|
||||
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());
|
||||
.arg("--lazaret-private-process-role")
|
||||
.arg("launch-wrapper");
|
||||
append_parent_identity(&mut command, parent);
|
||||
command.arg("--").arg("/usr/bin/touch").arg(&marker);
|
||||
command.stdin(Stdio::from(temporary_file(&bytes)?));
|
||||
if command.status()?.success() || marker.exists() {
|
||||
return Err(std::io::Error::other("malformed barrier released target"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tempfile_file(bytes: &[u8]) -> File {
|
||||
fn temporary_file(bytes: &[u8]) -> std::io::Result<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
|
||||
.open(&path)?;
|
||||
file.write_all(bytes)?;
|
||||
file.rewind()?;
|
||||
std::fs::remove_file(path)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn sa_nocldwait_refuses() -> std::io::Result<()> {
|
||||
let marker = marker("sa-nocldwait");
|
||||
let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("sa_nocldwait.py");
|
||||
let output = std::process::Command::new("uv")
|
||||
.args([
|
||||
"--cache-dir",
|
||||
"/tmp/lazaret-uv-cache",
|
||||
"run",
|
||||
"--no-project",
|
||||
"python",
|
||||
])
|
||||
.arg(script)
|
||||
.arg(std::env::current_exe()?)
|
||||
.arg(&marker)
|
||||
.output()?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if output.status.success()
|
||||
|| marker.exists()
|
||||
|| !stderr.contains("SIGCHLD semantic probe was not reapable")
|
||||
{
|
||||
Err(std::io::Error::other("SA_NOCLDWAIT was accepted"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
fn foreign_non_child_refuses() -> std::io::Result<()> {
|
||||
let pidfd = pidfd_open(rustix::process::getpid(), PidfdFlags::empty())?;
|
||||
if verify_direct_child_pidfd(pidfd.as_fd()).is_err() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(
|
||||
"foreign non-child pidfd was accepted",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_global_broker_serializes_concurrent_acquisitions() {
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn concurrent_acquisitions_serialize() -> std::io::Result<()> {
|
||||
let parent = current_process_identity()?;
|
||||
let start = Arc::new(Barrier::new(9));
|
||||
let inside = Arc::new(AtomicU64::new(0));
|
||||
let maximum = Arc::new(AtomicU64::new(0));
|
||||
let inside = Arc::new(AtomicUsize::new(0));
|
||||
let maximum = Arc::new(AtomicUsize::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 || {
|
||||
std::thread::spawn(move || -> std::io::Result<()> {
|
||||
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();
|
||||
let process = child_broker().acquire(
|
||||
parent,
|
||||
context(),
|
||||
&TargetCommand::new("/bin/true"),
|
||||
|staged| {
|
||||
let active = inside.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
maximum.fetch_max(active, Ordering::SeqCst);
|
||||
std::thread::yield_now();
|
||||
inside.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = staged;
|
||||
Ok(())
|
||||
},
|
||||
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
|
||||
)?;
|
||||
child_broker().reap(process)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
start.wait();
|
||||
for worker in workers {
|
||||
worker.join().unwrap();
|
||||
worker
|
||||
.join()
|
||||
.map_err(|_| std::io::Error::other("worker panicked"))??;
|
||||
}
|
||||
if maximum.load(Ordering::SeqCst) == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other("broker acquisition overlapped"))
|
||||
}
|
||||
assert_eq!(maximum.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ 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()
|
||||
if lazaret_linux::capability::irreversibly_drop(CapabilitySet::NET_ADMIN).is_err()
|
||||
|| lazaret_linux::capability::inspect(CapabilitySet::NET_ADMIN).ok() != Some(before)
|
||||
{
|
||||
return ExitCode::FAILURE;
|
||||
|
|
@ -19,10 +19,30 @@ fn main() -> ExitCode {
|
|||
.args(["--user", "--map-root-user"])
|
||||
.arg(std::env::current_exe().expect("test executable path"))
|
||||
.env("LAZARET_CAPABILITY_DROP_ISOLATED", "1")
|
||||
.status()
|
||||
.output()
|
||||
{
|
||||
Ok(status) if status.success() => ExitCode::SUCCESS,
|
||||
Ok(_) | Err(_) => ExitCode::FAILURE,
|
||||
Ok(output) if output.status.success() => ExitCode::SUCCESS,
|
||||
Ok(output)
|
||||
if String::from_utf8_lossy(&output.stderr).contains("Operation not permitted") =>
|
||||
{
|
||||
eprintln!("SKIP isolated capability-positive branch: user namespaces unavailable");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Ok(output) => {
|
||||
eprintln!(
|
||||
"isolated capability-positive branch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("SKIP isolated capability-positive branch: unshare is unavailable");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("failed to run isolated capability-positive branch: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
9
crates/lazaret-linux/tests/sa_nocldwait.c
Normal file
9
crates/lazaret-linux/tests/sa_nocldwait.c
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#include <signal.h>
|
||||
|
||||
__attribute__((constructor)) static void set_sa_nocldwait(void) {
|
||||
struct sigaction action = {0};
|
||||
action.sa_handler = SIG_DFL;
|
||||
action.sa_flags = SA_NOCLDWAIT;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGCHLD, &action, 0);
|
||||
}
|
||||
21
crates/lazaret-linux/tests/sa_nocldwait.py
Normal file
21
crates/lazaret-linux/tests/sa_nocldwait.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
source = pathlib.Path(__file__).with_suffix(".c")
|
||||
with tempfile.TemporaryDirectory(prefix="lazaret-sa-nocldwait-") as directory:
|
||||
preload = pathlib.Path(directory) / "sa_nocldwait.so"
|
||||
subprocess.run(
|
||||
["cc", "-shared", "-fPIC", "-o", preload, source],
|
||||
check=True,
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment["LD_PRELOAD"] = str(preload)
|
||||
result = subprocess.run(
|
||||
[sys.argv[1], "--run-acquisition", "success", sys.argv[2]],
|
||||
env=environment,
|
||||
)
|
||||
raise SystemExit(result.returncode)
|
||||
Loading…
Reference in a new issue