diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c14e2c27..33896f62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,27 @@ jobs: coverage-script: scripts/check-coverage.sh module-e2e-script: scripts/test-e2e.sh feature-test-script: scripts/test-features.sh + apt-packages: bubblewrap + native-resource-live: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 + - name: Install rootless native jail prerequisites + run: | + source scripts/ci/prepare-rootless-native-resources.sh + - name: Run required Linux native resource proofs + shell: bash + run: | + set -euo pipefail + uid="$(id -u)" + runtime_dir="/run/user/${uid}" + export XDG_RUNTIME_DIR="$runtime_dir" + export DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus" + TINYBOX_REQUIRE_NATIVE_RESOURCE_TESTS=1 cargo test -j1 -p tinybox-module native_ + TINYBOX_REQUIRE_NATIVE_RESOURCE_TESTS=1 cargo test -j1 -p tinybox-module resource_aware_bus_exec_cancellation_reports_events_and_reaps_descendants docker-network-live: name: Docker Egress and denied network connectivity runs-on: ubuntu-22.04 diff --git a/crates/tinybox-bus/src/jail.rs b/crates/tinybox-bus/src/jail.rs index 9f2dce9e..c4d686a8 100644 --- a/crates/tinybox-bus/src/jail.rs +++ b/crates/tinybox-bus/src/jail.rs @@ -38,6 +38,36 @@ pub struct NativeJailConfig { pub resources: Option, } +/// Hard resource limits requested by the contract 2.1 native jail members. +/// Each dimension is independently optional so a backend can enforce a real +/// supported subset without weakening a requested limit. Disk is represented +/// explicitly so implementations must refuse it when they cannot enforce it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeJailResourceRequest { + /// CPU budget in thousandths of one CPU (for example, 1 is 0.1%). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_millis: Option, + /// Maximum resident memory in bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_bytes: Option, + /// Maximum number of processes and threads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pids_max: Option, + /// Maximum writable disk bytes. Backends without a hard disk quota refuse. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk_bytes: Option, +} + +/// Source-compatible extension of [`NativeJailConfig`] for contract 2.1. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeJailRequest { + /// Existing filesystem, network and process jail configuration. + pub jail: NativeJailConfig, + /// Additional hard resource dimensions; any explicit unsupported one fails closed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, +} + #[cfg(test)] #[path = "jail_tests.rs"] mod tests; diff --git a/crates/tinybox-bus/src/jail_tests.rs b/crates/tinybox-bus/src/jail_tests.rs index e1193ef9..4551ed5b 100644 --- a/crates/tinybox-bus/src/jail_tests.rs +++ b/crates/tinybox-bus/src/jail_tests.rs @@ -66,3 +66,37 @@ fn native_resource_limits_are_optional_typed_and_round_trip() -> Result<(), serd assert_eq!(serde_json::to_value(config)?, request); Ok(()) } + +#[test] +fn native_jail_21_resource_subset_is_additive_and_keeps_disk_explicit() +-> Result<(), serde_json::Error> { + let jail = NativeJailConfig { + read_only: vec![], + read_write: vec![], + allow_net: false, + allow_subprocess: true, + enforcement: JailEnforcement::Enforced, + require_process_isolation: true, + deny_read: vec![], + resources: None, + }; + let request = NativeJailRequest { + jail: jail.clone(), + resources: Some(NativeJailResourceRequest { + cpu_millis: Some(1), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: Some(1024), + }), + }; + let encoded = serde_json::to_value(&request)?; + let decoded: NativeJailRequest = serde_json::from_value(encoded.clone())?; + assert_eq!(decoded, request); + assert_eq!(decoded.jail, jail); + assert_eq!( + decoded.resources.and_then(|limits| limits.disk_bytes), + Some(1024) + ); + assert_eq!(crate::NATIVE_JAIL_RESOURCES_VERSION, (2, 1)); + Ok(()) +} diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index ee6e9850..274ff7ee 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -19,7 +19,7 @@ mod grants; pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest}; mod jail; -pub use jail::{JailEnforcement, NativeJailConfig}; +pub use jail::{JailEnforcement, NativeJailConfig, NativeJailRequest, NativeJailResourceRequest}; mod shell_execution; mod shell_selection; @@ -82,7 +82,19 @@ pub use files::{ /// 2.0 removes TinyBox-owned command policy classification and the /// `AnalyzeShell.hidden_execution` policy signal. Generic shell structure facts /// and transport helpers remain. -pub const CONTRACT_VERSION: (u32, u32) = (2, 0); +/// 2.1 adds separate resource-aware native jail request members without +/// changing the 2.0 Rust request shapes. +pub const CONTRACT_VERSION: (u32, u32) = (2, 1); + +/// Minimum contract version required for resource-aware native jail members. +pub const NATIVE_JAIL_RESOURCES_VERSION: (u32, u32) = (2, 1); + +/// Whether a module explicitly advertises support for the resource-aware +/// native jail members. Hosts must check this before sending a 2.1 request. +#[must_use] +pub fn supports_native_jail_resources(module: (u32, u32)) -> bool { + module.0 == NATIVE_JAIL_RESOURCES_VERSION.0 && module.1 >= NATIVE_JAIL_RESOURCES_VERSION.1 +} /// Whether this host vocabulary can bind to a module's advertised version. /// The major must match and the module's minor must include every host member. @@ -207,6 +219,8 @@ pub mod names { pub const KILL_STDIO: &str = "KillStdio"; /// Wire name of the `StdioStatus` member. pub const STDIO_STATUS: &str = "StdioStatus"; + /// Wire name of the 2.1 resource-aware jailed exec member. + pub const START_JAILED_EXEC_WITH_RESOURCES: &str = "StartJailedExecWithResources"; } /// Supported methods in this contract, including the original discovery @@ -253,6 +267,7 @@ pub const METHODS: &[&str] = &[ names::CLOSE_STDIN, names::KILL_STDIO, names::STDIO_STATUS, + names::START_JAILED_EXEC_WITH_RESOURCES, ]; /// Facts about the detected native directory-jail backend. diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index bc52af42..3e42c923 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -51,7 +51,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso assert_eq!(METHODS[28], "CleanupDocker"); assert_eq!(METHODS[29], "RunDockerWithoutWorkspace"); assert_eq!(METHODS[33], "StartNativeShellExec"); - assert_eq!(CONTRACT_VERSION, (2, 0)); + assert_eq!(CONTRACT_VERSION, (2, 1)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -129,7 +129,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (2, 0)); + assert_eq!(CONTRACT_VERSION, (2, 1)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 8))); @@ -143,14 +143,18 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() assert!(!is_compatible((0, 99))); assert!(!is_compatible((1, 22))); assert!(!is_compatible((3, 0))); + assert!(!is_compatible((2, 0))); assert!(is_compatible((2, 1))); + assert!(!supports_native_jail_resources((2, 0))); + assert!(supports_native_jail_resources((2, 1))); + assert!(supports_native_jail_resources((2, 2))); let capabilities = ModuleCapabilities { contract_version: CONTRACT_VERSION, create_backends: vec!["passthrough".into()], exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[2,0],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[2,1],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -169,7 +173,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (2, 0)); + assert_eq!(CONTRACT_VERSION, (2, 1)); } #[test] @@ -182,7 +186,7 @@ fn shell_analysis_wire_shape_contains_only_structural_facts() -> Result<(), serd #[test] fn contract_two_drops_command_classification_but_keeps_structural_shell_analysis() { - assert_eq!(CONTRACT_VERSION, (2, 0)); + assert_eq!(CONTRACT_VERSION, (2, 1)); assert!(!METHODS.contains(&"AnalyzeCommand")); assert!(METHODS.contains(&"AnalyzeShell")); } @@ -358,6 +362,7 @@ fn named_members_resolve_to_the_method_catalogue_in_order() { CLOSE_STDIN, KILL_STDIO, STDIO_STATUS, + START_JAILED_EXEC_WITH_RESOURCES, ]; assert_eq!(METHODS, &expected[..]); assert_eq!(DESCRIBE, "Describe"); diff --git a/crates/tinybox-jail/src/bwrap/args.rs b/crates/tinybox-jail/src/bwrap/args.rs index c6304f54..1444d4fe 100644 --- a/crates/tinybox-jail/src/bwrap/args.rs +++ b/crates/tinybox-jail/src/bwrap/args.rs @@ -318,9 +318,10 @@ fn deny_masks(denied: &[PathBuf], mounts: &[Mount]) -> io::Result> os("/dev/null"), deny.clone().into_os_string(), ]), - // Bubblewrap creates a missing --tmpfs destination on the host - // before mounting over it. Refuse instead of mutating a granted - // directory or preventing a future file from being created there. + // Bubblewrap's `--tmpfs` creates a missing destination on the host + // before mounting it. Refuse prospective denials rather than + // mutating a granted host directory or preventing that path from + // later being created as a file. Err(error) if error.kind() == io::ErrorKind::NotFound => { return Err(io::Error::new( io::ErrorKind::NotFound, diff --git a/crates/tinybox-jail/src/bwrap/args_tests.rs b/crates/tinybox-jail/src/bwrap/args_tests.rs index 030c0f2b..c52adde5 100644 --- a/crates/tinybox-jail/src/bwrap/args_tests.rs +++ b/crates/tinybox-jail/src/bwrap/args_tests.rs @@ -240,10 +240,7 @@ fn deny_read_of_a_missing_path_is_refused() -> io::Result<()> { let root = tempfile::tempdir()?; let mut j = jail(root.path()); j.deny_read = vec![root.path().join("later")]; - assert_eq!( - jail_args(&j, None, OPTS).err().map(|error| error.kind()), - Some(io::ErrorKind::NotFound) - ); + assert_eq!(build(&j).unwrap_err().kind(), io::ErrorKind::NotFound); Ok(()) } @@ -254,10 +251,7 @@ fn missing_denial_inside_a_read_only_grant_is_refused() -> io::Result<()> { let denied = grant.path().join("later"); let mut j = jail(root.path()).add_read_only(grant.path()); j.deny_read = vec![denied.clone()]; - assert_eq!( - jail_args(&j, None, OPTS).err().map(|error| error.kind()), - Some(io::ErrorKind::NotFound) - ); + assert_eq!(build(&j).unwrap_err().kind(), io::ErrorKind::NotFound); Ok(()) } diff --git a/crates/tinybox-linux/src/lib.rs b/crates/tinybox-linux/src/lib.rs index eb1c023b..c00047d8 100644 --- a/crates/tinybox-linux/src/lib.rs +++ b/crates/tinybox-linux/src/lib.rs @@ -45,4 +45,7 @@ mod sandbox; pub use probe::{NamespaceProbe, UsernsStatus}; -pub use sandbox::{NAME, NamespaceSandbox, WORKSPACE_MOUNT}; +pub use sandbox::{ + NAME, NamespaceSandbox, NativeJailCommand, WORKSPACE_MOUNT, native_jail_argv, + native_jail_argv_with_programs, trusted_systemd_run_program, +}; diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 2102cfaa..52f577cb 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -6,7 +6,9 @@ //! only happens at runtime. Runtime capability probes are kept separate from //! the pure argument builders. -use std::path::Path; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + use tinybox_core::{BoxSpec, Capability, Error, ExecRequest, Resources, Result, WorkspaceSource}; use super::NAME; @@ -22,6 +24,7 @@ pub const WORKSPACE_MOUNT: &str = "/workspace"; /// else. const USR: &str = "/usr"; const SYSTEMD_EXPAND_ENVIRONMENT_FLAG: &[u8] = b"--expand-environment"; +static NATIVE_SCOPE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Files a normal command expects to exist. /// @@ -125,7 +128,7 @@ pub(super) fn cgroup_prefix( "--property".to_owned(), // systemd takes CPU as a percentage where 100% is one core, and // thousandths convert by a factor of ten. - format!("CPUQuota={}%", resources.cpu_millis / 10), + format!("CPUQuota={}%", cpu_quota(resources.cpu_millis)), "--property".to_owned(), format!("TasksMax={}", resources.pids_max), "--".to_owned(), @@ -159,6 +162,388 @@ pub(super) fn parse_expand_environment_help( })) } +/// Convert thousandths of one CPU to systemd's percentage without dropping +/// the tenth-percent digit (one milli-CPU is `0.1%`, not `0%`). +fn cpu_quota(cpu_millis: u32) -> String { + let whole = cpu_millis / 10; + let tenth = cpu_millis % 10; + if tenth == 0 { + whole.to_string() + } else { + format!("{whole}.{tenth}") + } +} + +/// Fully validated input for the namespace-backed native directory jail. +/// This is separate from [`BoxSpec`]: a native jail binds the caller's grants +/// at their declared paths and never broadens them to a workspace image. +#[derive(Debug, Clone)] +pub struct NativeJailCommand { + /// Writable workspace root. + pub root: PathBuf, + /// Working directory, required to be under `root`. + pub cwd: PathBuf, + /// Additional read-only paths. + pub read_only: Vec, + /// Additional writable paths. + pub read_write: Vec, + /// Paths to mask after applying grants. + pub deny_read: Vec, + /// Whether the process may use network sockets. + pub allow_net: bool, + /// Whether the process may create child processes. + pub allow_subprocess: bool, + /// Hard CPU limit in thousandths of one CPU. + pub cpu_millis: Option, + /// Hard resident memory limit in bytes. + pub memory_bytes: Option, + /// Hard task count limit. + pub pids_max: Option, + /// Program and arguments, never parsed by a shell. + pub argv: Vec, + /// Environment installed by bubblewrap only after namespace setup. + pub env: BTreeMap, +} + +/// Build the trusted systemd-run and bubblewrap launcher for a native jail. +/// +/// Explicit unsupported dimensions and unsafe request shapes are rejected; +/// no requested limit is rounded up or dropped. +/// +/// # Errors +/// Returns an error when a request is invalid, unsupported, or systemd's +/// command-argument expansion behavior cannot be verified. +pub fn native_jail_argv(request: &NativeJailCommand) -> std::io::Result> { + native_jail_argv_with_programs( + request, + &trusted_systemd_run_program()?, + &trusted_bwrap_program()?, + ) +} + +/// Build the native jail launcher using the already verified Bubblewrap binary +/// selected by the owning `tinybox-jail` backend. +/// +/// # Errors +/// Returns an error unless both launchers are absolute trusted executables and +/// systemd's command-argument expansion behavior can be verified. +pub fn native_jail_argv_with_programs( + request: &NativeJailCommand, + systemd_run: &Path, + bwrap: &Path, +) -> std::io::Result> { + validate_native_jail_command(request)?; + let systemd_run = validate_trusted_program(systemd_run)?; + let bwrap = validate_trusted_program(bwrap)?; + let supports_expand_environment = systemd_run_supports_safe_arguments(&systemd_run)?; + let argv = native_bwrap_argv(request, supports_expand_environment, &systemd_run, &bwrap); + Ok(native_launcher_argv(argv)) +} + +/// Find systemd-run only in fixed system locations and return its trusted +/// canonical path. This does not consult the caller's `PATH`. +/// +/// # Errors +/// Returns `NotFound` when no trusted executable is available in those paths. +pub fn trusted_systemd_run_program() -> std::io::Result { + [ + Path::new("/usr/bin/systemd-run"), + Path::new("/usr/local/bin/systemd-run"), + Path::new("/bin/systemd-run"), + ] + .into_iter() + .find_map(|path| validate_trusted_program(path).ok()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no trusted systemd-run executable in fixed system locations", + ) + }) +} + +fn trusted_bwrap_program() -> std::io::Result { + [Path::new("/usr/bin/bwrap"), Path::new("/bin/bwrap")] + .into_iter() + .find_map(|path| validate_trusted_program(path).ok()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no trusted bwrap executable in fixed system locations", + ) + }) +} + +#[cfg(unix)] +fn validate_trusted_program(path: &Path) -> std::io::Result { + use std::io::{Error, ErrorKind}; + use std::os::unix::fs::MetadataExt as _; + if !path.is_absolute() { + return Err(Error::new( + ErrorKind::PermissionDenied, + "launcher path must be absolute", + )); + } + let canonical = path.canonicalize()?; + let mut current = std::path::PathBuf::from("/"); + for component in canonical.components().skip(1) { + current.push(component); + let metadata = std::fs::metadata(¤t)?; + if metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { + return Err(Error::new( + ErrorKind::PermissionDenied, + "launcher path has a non-root-owned or writable ancestor", + )); + } + } + let metadata = std::fs::metadata(&canonical)?; + if !metadata.is_file() || metadata.mode() & 0o111 == 0 { + return Err(Error::new( + ErrorKind::PermissionDenied, + "launcher is not executable", + )); + } + Ok(canonical) +} + +#[cfg(not(unix))] +fn validate_trusted_program(_path: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "native Linux launchers require Unix filesystem ownership and mode checks", + )) +} + +fn validate_native_jail_command(request: &NativeJailCommand) -> std::io::Result<()> { + use std::io::{Error, ErrorKind}; + let invalid = || Error::new(ErrorKind::InvalidInput, "invalid native jail command"); + if request.argv.is_empty() + || request.argv.iter().any(|part| part.contains('\0')) + || request.argv[0].is_empty() + || request + .env + .iter() + .any(|(key, value)| key.is_empty() || key.contains(['=', '\0']) || value.contains('\0')) + || request.deny_read.iter().any(|denied| { + request.root.starts_with(denied) + || request + .read_write + .iter() + .any(|grant| grant.starts_with(denied)) + }) + || !request.cwd.starts_with(&request.root) + || !matches!(request.root.canonicalize(), Ok(root) if root.is_dir() && root == request.root) + || !matches!(request.cwd.canonicalize(), Ok(cwd) if cwd.starts_with(&request.root) && cwd == request.cwd) + || request + .read_only + .iter() + .chain(&request.read_write) + .any(|path| !matches!(path.canonicalize(), Ok(canonical) if canonical == *path)) + || std::iter::once(&request.root) + .chain(&request.read_only) + .chain(&request.read_write) + .any(|path| { + ["/run", "/sys", "/proc"] + .iter() + .any(|protected| path.starts_with(protected)) + }) + { + return Err(invalid()); + } + if request.cpu_millis == Some(0) + || request.memory_bytes == Some(0) + || request.pids_max == Some(0) + { + return Err(invalid()); + } + if request.cpu_millis.is_none() && request.memory_bytes.is_none() && request.pids_max.is_none() + { + return Err(invalid()); + } + if request.allow_net { + return Err(Error::new( + ErrorKind::Unsupported, + "resource-aware native jail requires a private network namespace", + )); + } + if !request.allow_subprocess { + return Err(Error::new( + ErrorKind::Unsupported, + "subprocess denial is unsupported", + )); + } + + Ok(()) +} + +fn systemd_run_supports_safe_arguments(systemd_run: &Path) -> std::io::Result { + use std::io::{Error, ErrorKind}; + // `systemd-run` versions that support variable expansion apply it to + // command arguments before starting bwrap. Probe the same advertised + // capability used by the owning namespace backend and disable expansion + // before placing any target-controlled argv on the command line. + let probe = std::process::Command::new(systemd_run) + .arg("--help") + .env("LC_ALL", "C") + .output()?; + let supports_expand_environment = + parse_expand_environment_help(probe.status.success(), &probe.stdout, &probe.stderr) + .map_err(|_| { + Error::new( + ErrorKind::Unsupported, + "cannot verify systemd-run argument handling", + ) + })?; + Ok(supports_expand_environment) +} + +fn native_bwrap_argv( + request: &NativeJailCommand, + supports_expand_environment: bool, + systemd_run: &Path, + bwrap: &Path, +) -> Vec { + let mut argv = vec![ + systemd_run.display().to_string(), + "--user".to_owned(), + "--scope".to_owned(), + "--quiet".to_owned(), + format!( + "--unit=tinybox-native-{}-{}.scope", + std::process::id(), + NATIVE_SCOPE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ), + ]; + if supports_expand_environment { + argv.push("--expand-environment=no".into()); + } + if let Some(value) = request.memory_bytes { + argv.push(format!("--property=MemoryMax={value}")); + argv.push("--property=MemorySwapMax=0".to_owned()); + // Keep the scope present long enough for its supervisor and observers + // to report the kernel OOM event and complete bounded cleanup. + argv.push("--property=OOMPolicy=continue".to_owned()); + } + if let Some(value) = request.cpu_millis { + argv.push(format!("--property=CPUQuota={}%", cpu_quota_u64(value))); + } + if let Some(value) = request.pids_max { + argv.push(format!("--property=TasksMax={value}")); + } + argv.push("--".to_owned()); + argv.push(bwrap.display().to_string()); + for flag in [ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--unshare-cgroup", + ] { + argv.push(flag.to_owned()); + } + // The private network namespace also isolates AF_UNIX abstract sockets, + // closing the same-UID user-manager escape path. + argv.push("--unshare-net".to_owned()); + argv.extend(["--die-with-parent".into(), "--new-session".into()]); + argv.extend(root_filesystem()); + // A user-scope manager socket or host cgroup filesystem would let a + // same-UID workload escape the transient resource scope. + argv.extend([ + "--tmpfs".into(), + "/run".into(), + "--tmpfs".into(), + "/sys".into(), + ]); + ensure_mount_parents(&mut argv, request); + argv.extend([ + "--bind".into(), + request.root.display().to_string(), + request.root.display().to_string(), + ]); + for (source, destination, mode) in request + .read_only + .iter() + .map(|path| (path, path, "--ro-bind")) + .chain(request.read_write.iter().map(|path| (path, path, "--bind"))) + { + argv.extend([ + mode.into(), + source.display().to_string(), + destination.display().to_string(), + ]); + } + for denied in &request.deny_read { + if denied.is_dir() { + argv.extend(["--tmpfs".into(), denied.display().to_string()]); + } else { + argv.extend([ + "--ro-bind".into(), + "/dev/null".into(), + denied.display().to_string(), + ]); + } + } + argv.extend([ + "--chdir".into(), + request.cwd.display().to_string(), + "--clearenv".into(), + ]); + for (key, value) in &request.env { + argv.extend(["--setenv".into(), key.clone(), value.clone()]); + } + argv.push("--".into()); + argv.extend(request.argv.iter().cloned()); + // A scope launcher inherits the host process's descriptors. The trusted + // shim closes every non-stdio descriptor before opening systemd's bus or + // starting bubblewrap, preventing a pre-opened manager/cgroup FD from + // bypassing the mount and network namespaces. Target values remain argv + // data and the shim receives only a fixed, sanitized launcher environment. + argv +} + +fn native_launcher_argv(argv: Vec) -> Vec { + let mut launcher = vec![ + "/bin/bash".to_owned(), + "--noprofile".to_owned(), + "--norc".to_owned(), + "-c".to_owned(), + "[ -d /proc/self/fd ] || exit 125; shopt -s nullglob; fdpaths=(/proc/self/fd/*); ((${#fdpaths[@]} > 0)) || exit 125; for fdpath in \"${fdpaths[@]}\"; do fd=${fdpath##*/}; case $fd in 0|1|2|*[!0-9]*) continue;; esac; eval \"exec ${fd}>&-\" || exit 125; done; unit=; for arg in \"$@\"; do case $arg in --unit=*) unit=${arg#--unit=};; esac; done; [ -n \"$unit\" ] || exit 125; \"$@\"; status=$?; if ! /usr/bin/systemctl --user stop \"$unit\"; then /usr/bin/systemctl --user is-active --quiet \"$unit\" && exit 125; fi; exit \"$status\"".to_owned(), + "tinybox-native-launcher".to_owned(), + ]; + launcher.extend(argv); + launcher +} + +fn cpu_quota_u64(cpu_millis: u64) -> String { + let whole = cpu_millis / 10; + let tenth = cpu_millis % 10; + if tenth == 0 { + whole.to_string() + } else { + format!("{whole}.{tenth}") + } +} + +fn ensure_mount_parents(argv: &mut Vec, request: &NativeJailCommand) { + let mut dirs = std::collections::BTreeSet::new(); + for path in std::iter::once(&request.root) + .chain(&request.read_only) + .chain(&request.read_write) + .chain(&request.deny_read) + { + let mut parent = path.parent(); + while let Some(path) = parent { + if path == Path::new("/") { + break; + } + dirs.insert(path.to_path_buf()); + parent = path.parent(); + } + } + for dir in dirs { + argv.extend(["--dir".into(), dir.display().to_string()]); + } +} + /// The `bwrap` invocation itself. fn bwrap(spec: &BoxSpec, request: &ExecRequest) -> Result> { let workspace = workspace_dir(spec)?; diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 0a83c3cd..00259110 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -11,7 +11,10 @@ use tinybox_core::{ mod args; -pub use args::WORKSPACE_MOUNT; +pub use args::{ + NativeJailCommand, WORKSPACE_MOUNT, native_jail_argv, native_jail_argv_with_programs, + trusted_systemd_run_program, +}; /// The name this sandbox registers under. pub const NAME: &str = "namespace"; diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 3d0e8804..b3bb18af 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -5,6 +5,12 @@ //! then confirms the boundary actually holds against a real kernel — this file //! pins the intent, that one pins the reality. +#[cfg(target_os = "linux")] +use std::collections::BTreeMap; +#[cfg(target_os = "linux")] +use std::os::unix::fs::PermissionsExt as _; +#[cfg(target_os = "linux")] +use std::path::Path; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use async_trait::async_trait; @@ -325,6 +331,7 @@ async fn limits_wrap_the_sandbox_in_a_systemd_scope() -> Result<()> { let argv = host.last(); assert_eq!(argv.first().map(String::as_str), Some("systemd-run")); + assert!(has(&argv, "systemd-run")); assert!(has(&argv, "--user")); assert!(has(&argv, "--scope")); assert!(has(&argv, "--expand-environment=no")); @@ -417,6 +424,191 @@ async fn failed_systemd_option_probe_refuses_without_launching_workload() -> Res Ok(()) } +#[tokio::test] +async fn fractional_cpu_quota_is_not_rounded_down_to_zero() -> Result<()> { + let host = Arc::new(RecordingHost::default()); + let sandbox = + NamespaceSandbox::new(host.clone(), Arc::new(MemoryStore::new())).with_cgroup_limits(); + let spec = spec()?.with_resources(Resources { + cpu_millis: 1, + memory_bytes: 64 * 1024 * 1024, + pids_max: 16, + disk_bytes: 1, + }); + let info = sandbox.create(&spec).await?; + sandbox.exec(&info.id, &ExecRequest::new(["true"])).await?; + let argv = host.last(); + assert!(has(&argv, "CPUQuota=0.1%")); + assert!(!has(&argv, "CPUQuota=0%")); + Ok(()) +} + +#[test] +#[cfg(target_os = "linux")] +fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let cwd = root.path().join("work"); + std::fs::create_dir(&cwd)?; + let grant = root.path().join("read-only"); + std::fs::create_dir(&grant)?; + let systemd_run = crate::trusted_systemd_run_program()?; + let argv = crate::native_jail_argv_with_programs( + &crate::NativeJailCommand { + root: root.path().to_path_buf(), + cwd, + read_only: vec![grant.clone()], + read_write: Vec::new(), + deny_read: Vec::new(), + allow_net: false, + allow_subprocess: true, + cpu_millis: Some(1), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + argv: vec!["/usr/bin/env".into()], + env: BTreeMap::from([("BASH_ENV".into(), "/tmp/target-init".into())]), + }, + &systemd_run, + Path::new("/bin/true"), + )?; + assert_eq!(argv.first().map(String::as_str), Some("/bin/bash")); + assert!(argv.contains(&systemd_run.display().to_string())); + assert!(systemd_run.is_absolute()); + let bubblewrap = argv + .iter() + .position(|part| part == "--") + .and_then(|position| argv.get(position + 1)) + .ok_or("missing bubblewrap command")?; + assert!(std::path::Path::new(bubblewrap).is_absolute()); + assert!(has(&argv, "--property=CPUQuota=0.1%")); + assert!(has(&argv, "--property=MemoryMax=67108864")); + assert!(has(&argv, "--property=MemorySwapMax=0")); + assert!(has(&argv, "--property=TasksMax=16")); + assert!(has(&argv, "--unshare-cgroup")); + assert!(has(&argv, "--unshare-net")); + assert!(has(&argv, "--tmpfs")); + assert!(has(&argv, "/run")); + assert!(has(&argv, "/sys")); + assert!(has(&argv, "--ro-bind")); + assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); + assert!(has(&argv, "BASH_ENV")); + let systemd_end = argv + .iter() + .position(|part| part == "--") + .ok_or("missing systemd delimiter")?; + assert!( + !argv[..systemd_end] + .iter() + .any(|part| part.contains("BASH_ENV")) + ); + Ok(()) +} + +#[test] +#[cfg(target_os = "linux")] +fn native_jail_does_not_resolve_trusted_launchers_from_target_path() +-> std::result::Result<(), Box> { + const CHILD: &str = + "sandbox::test::native_jail_does_not_resolve_trusted_launchers_from_target_path"; + if let Some(marker) = std::env::var_os("TINYBOX_PATH_HIJACK_MARKER") { + let root = tempfile::tempdir()?; + let cwd = root.path().join("work"); + std::fs::create_dir(&cwd)?; + let result = crate::native_jail_argv(&crate::NativeJailCommand { + root: root.path().to_path_buf(), + cwd, + read_only: Vec::new(), + read_write: Vec::new(), + deny_read: Vec::new(), + allow_net: false, + allow_subprocess: true, + cpu_millis: Some(1), + memory_bytes: None, + pids_max: None, + argv: vec!["/bin/true".into()], + env: BTreeMap::new(), + }); + if let Err(error) = result { + if error.kind() == std::io::ErrorKind::NotFound + && error.to_string() == "no trusted bwrap executable in fixed system locations" + { + assert!(!std::path::Path::new(&marker).exists()); + return Ok(()); + } + return Err(error.into()); + } + assert!(!std::path::Path::new(&marker).exists()); + return Ok(()); + } + + let fixture = tempfile::tempdir()?; + let marker = fixture.path().join("helper-ran"); + let helper = fixture.path().join("systemd-run"); + std::fs::write( + &helper, + "#!/bin/sh\nprintf hijacked > \"$TINYBOX_PATH_HIJACK_MARKER\"\nprintf '%s\\n' '--expand-environment BOOL'\n", + )?; + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755))?; + + let output = std::process::Command::new(std::env::current_exe()?) + .args(["--exact", CHILD, "--nocapture"]) + .env("PATH", fixture.path()) + .env("TINYBOX_PATH_HIJACK_MARKER", &marker) + .output()?; + assert!( + output.status.success(), + "child failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!marker.exists(), "a PATH-controlled launcher executed"); + Ok(()) +} + +#[test] +#[cfg(target_os = "linux")] +fn native_jail_command_refuses_invalid_resource_and_isolation_requests() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let cwd = root.path().join("work"); + std::fs::create_dir(&cwd)?; + let request = || crate::NativeJailCommand { + root: root.path().to_path_buf(), + cwd: cwd.clone(), + read_only: Vec::new(), + read_write: Vec::new(), + deny_read: Vec::new(), + allow_net: false, + allow_subprocess: true, + cpu_millis: Some(1000), + memory_bytes: None, + pids_max: None, + argv: vec!["/bin/true".into()], + env: BTreeMap::new(), + }; + + let mut invalid = request(); + invalid.argv.clear(); + assert!(crate::native_jail_argv(&invalid).is_err()); + + let mut invalid = request(); + invalid.cpu_millis = Some(0); + assert!(crate::native_jail_argv(&invalid).is_err()); + + let mut invalid = request(); + invalid.cpu_millis = None; + assert!(crate::native_jail_argv(&invalid).is_err()); + + let mut unsupported = request(); + unsupported.allow_net = true; + assert!(crate::native_jail_argv(&unsupported).is_err()); + + let mut unsupported = request(); + unsupported.allow_subprocess = false; + assert!(crate::native_jail_argv(&unsupported).is_err()); + Ok(()) +} + #[tokio::test] async fn without_limits_there_is_no_scope_at_all() -> Result<()> { let (sandbox, host) = sandbox(); diff --git a/crates/tinybox-module/Cargo.toml b/crates/tinybox-module/Cargo.toml index ab84b57b..516727be 100644 --- a/crates/tinybox-module/Cargo.toml +++ b/crates/tinybox-module/Cargo.toml @@ -53,7 +53,7 @@ tempfile = "3" [target.'cfg(unix)'.dependencies] # Stdio children own a process group that is killed as a unit. -nix = { version = "0.31", default-features = false, features = ["signal", "process"] } +nix = { version = "0.31", default-features = false, features = ["signal", "process", "fs"] } [dev-dependencies] # Tests serve the storage objects on the in-memory broker the module connects to. diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 2ec338cd..6490991b 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -279,6 +279,23 @@ impl BoxService { .await } + /// Start a jailed execution with the separately negotiated contract 2.1 + /// resource subset. A module advertising an older minor must refuse this + /// member rather than deserialize and drop its constraints. + async fn start_jailed_exec_with_resources( + &self, + request: SpawnRequest, + config: tinybox_bus::NativeJailRequest, + ) -> TinyBusResult { + let resources = self.resources.clone(); + finish_operation(tokio::spawn(async move { + resources + .start_jailed_exec_with_resources(request, config) + .await + })) + .await + } + /// Execute platform shell text using the original two-argument contract. async fn start_shell_exec( &self, @@ -580,7 +597,7 @@ tinybus_module::module_export_optional_static! { setup = setup, worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], - methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus"], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus", "StartJailedExecWithResources"], signals = [], requires = [], optional = [], diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 7476a132..d4a41083 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -1,9 +1,11 @@ //! Validate approved native grants before reserving or starting a child. use std::{ + ffi::OsString, path::{Component, Path}, - sync::Arc, + process::{Child, Command}, + sync::{Arc, Mutex}, }; -use tinybox_bus::{JailEnforcement, NativeJailConfig}; +use tinybox_bus::{JailEnforcement, NativeJailConfig, NativeJailRequest}; use tinybox_jail::{Jail, JailBackend}; use tinybus::{Error, Result}; @@ -229,6 +231,224 @@ fn unavailable() -> Error { ) } +/// A private backend whose sole accepted command is the validated launcher +/// constructed by `prepare_with_resources`. It is created per execution and +/// consumes that command exactly once at spawn. +struct NativeResourceBackend { + expected: Mutex>>, + requested: Mutex>, +} + +impl NativeResourceBackend { + fn new() -> Self { + Self { + expected: Mutex::new(None), + requested: Mutex::new(None), + } + } + + fn bind(&self, argv: &[String]) -> Result<()> { + let mut expected = self.expected.lock().map_err(|_| unavailable())?; + if expected.is_some() { + return Err(unavailable()); + } + *expected = Some(argv.iter().map(OsString::from).collect()); + Ok(()) + } + + fn bind_resources(&self, resources: tinybox_bus::NativeJailResourceRequest) -> Result<()> { + let mut requested = self.requested.lock().map_err(|_| unavailable())?; + if requested.is_some() { + return Err(unavailable()); + } + *requested = Some(resources); + Ok(()) + } +} + +impl JailBackend for NativeResourceBackend { + fn name(&self) -> &'static str { + "tinybox-linux-native-resources" + } + + fn is_available(&self) -> bool { + native_launchers_available() + } + + fn isolation(&self) -> tinybox_core::IsolationLevel { + tinybox_core::IsolationLevel::Kernel + } + + fn is_suitable_for_untrusted_code(&self) -> bool { + true + } + + fn carves_denials(&self) -> bool { + true + } + + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { + use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; + ConstraintSupport::NONE + .with(Constraint::Filesystem, Enforcement::Enforced) + .with(Constraint::Network, Enforcement::Enforced) + .with(Constraint::Subprocess, Enforcement::Enforced) + .with(Constraint::Cpu, Enforcement::Enforced) + .with(Constraint::Memory, Enforcement::Enforced) + .with(Constraint::Pids, Enforcement::Enforced) + .with(Constraint::Disk, Enforcement::Unsupported) + } + + fn spawn(&self, jail: &Jail, mut cmd: Command) -> std::io::Result { + if jail.resources.is_some() + || self + .requested + .lock() + .map_err(|_| std::io::Error::other("native resource request lock poisoned"))? + .is_none() + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "resource backend requires a 2.1 native request", + )); + } + let mut actual = vec![cmd.get_program().to_os_string()]; + actual.extend(cmd.get_args().map(std::ffi::OsStr::to_os_string)); + let mut expected = self + .expected + .lock() + .map_err(|_| std::io::Error::other("resource launcher plan lock poisoned"))?; + if expected.take().as_deref() != Some(actual.as_slice()) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "native resource launcher differs from validated plan", + )); + } + // The trusted supervisor shim closes inherited descriptors above + // stdio before starting the resource launcher. The live manager-FD + // escape test pins this behavior. + // JailLocalHost clears inherited variables before installing this + // fixed launcher environment. The target environment is carried only + // inside bubblewrap after its namespaces are active. + cmd.spawn() + } +} + +fn prepare_with_resources( + root: &Path, + request: &NativeJailRequest, + command: &mut tinybox_core::ExecRequest, + backend: &NativeResourceBackend, +) -> Result { + let resources = request.resources.ok_or_else(invalid)?; + if request.jail.resources.is_some() || resources.disk_bytes.is_some() { + return Err(unavailable()); + } + if [ + resources.cpu_millis, + resources.memory_bytes, + resources.pids_max, + ] + .into_iter() + .flatten() + .any(|value| value == 0) + || (resources.cpu_millis.is_none() + && resources.memory_bytes.is_none() + && resources.pids_max.is_none()) + { + return Err(invalid()); + } + backend.bind_resources(resources)?; + let jail = prepare(root, &request.jail, command, backend)?; + if request.jail.allow_net || !request.jail.allow_subprocess { + return Err(unavailable()); + } + let cwd = command.cwd.as_ref().ok_or_else(invalid)?; + let native = tinybox_linux::NativeJailCommand { + root: jail.root.clone(), + cwd: cwd.canonicalize().map_err(|_| invalid())?, + read_only: jail.read_only.clone(), + read_write: jail.read_write.clone(), + deny_read: jail.deny_read.clone(), + allow_net: jail.allow_net, + allow_subprocess: jail.allow_subprocess, + cpu_millis: resources.cpu_millis, + memory_bytes: resources.memory_bytes, + pids_max: resources.pids_max, + argv: command.argv.clone(), + env: command + .env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + }; + configure_resource_launcher(&native, command, backend)?; + Ok(jail) +} + +#[cfg(target_os = "linux")] +fn configure_resource_launcher( + native: &tinybox_linux::NativeJailCommand, + command: &mut tinybox_core::ExecRequest, + backend: &NativeResourceBackend, +) -> Result<()> { + let bwrap = tinybox_jail::BubblewrapBackend::new() + .program() + .map(Path::to_path_buf) + .ok_or_else(unavailable)?; + let systemd_run = tinybox_linux::trusted_systemd_run_program().map_err(|_| unavailable())?; + let argv = tinybox_linux::native_jail_argv_with_programs(native, &systemd_run, &bwrap) + .map_err(|_| unavailable())?; + backend.bind(&argv)?; + command.argv = argv; + command.env = launcher_environment(); + command.cwd = None; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn configure_resource_launcher( + _native: &tinybox_linux::NativeJailCommand, + _command: &mut tinybox_core::ExecRequest, + _backend: &NativeResourceBackend, +) -> Result<()> { + Err(unavailable()) +} + +#[cfg(target_os = "linux")] +fn native_launchers_available() -> bool { + tinybox_linux::trusted_systemd_run_program().is_ok() + && tinybox_jail::BubblewrapBackend::new().program().is_some() +} + +#[cfg(not(target_os = "linux"))] +fn native_launchers_available() -> bool { + false +} + +fn launcher_environment() -> std::collections::BTreeMap { + let mut env = std::collections::BTreeMap::new(); + for key in [ + "PATH", + "HOME", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "SYSTEMD_BUS_ADDRESS", + "LANG", + "LC_ALL", + "LC_CTYPE", + ] { + if let Some(value) = std::env::var_os(key).and_then(|value| value.into_string().ok()) { + env.insert(key.to_owned(), value); + } + } + env +} + +fn resource_backend() -> Arc { + Arc::new(NativeResourceBackend::new()) +} + /// Create an owned collector only after the native jail passes preflight. pub(super) fn collector( root: &Path, @@ -242,6 +462,19 @@ pub(super) fn collector( .map_err(|_| unavailable()) } +/// Create an owned collector for a separately negotiated contract 2.1 request. +pub(super) fn collector_with_resources( + root: &Path, + request: &NativeJailRequest, + command: &mut tinybox_core::ExecRequest, +) -> Result> { + let backend = resource_backend(); + let jail = prepare_with_resources(root, request, command, backend.as_ref())?; + tinybox_host::JailLocalHost::with_backend(jail, backend, tinybox_bus::MAX_OUTPUT_BYTES) + .map(Arc::new) + .map_err(|_| unavailable()) +} + #[cfg(test)] #[path = "native_jail_tests.rs"] mod tests; diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index b9d54674..999cb121 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -89,6 +89,97 @@ fn config() -> NativeJailConfig { } } +#[cfg(target_os = "linux")] +fn native_resource_runtime_available() -> bool { + use std::os::unix::fs::FileTypeExt; + use std::process::Command; + + let manager_socket = std::env::var("DBUS_SESSION_BUS_ADDRESS") + .ok() + .and_then(|address| address.strip_prefix("unix:path=").map(str::to_owned)) + .is_some_and(|path| { + std::fs::metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket()) + }); + let scope = Command::new("systemd-run") + .args([ + "--user", + "--scope", + "--quiet", + "--property=CPUQuota=100%", + "--property=MemoryMax=67108864", + "--property=MemorySwapMax=0", + "--property=TasksMax=16", + "--", + "/bin/true", + ]) + .status() + .is_ok_and(|status| status.success()); + let namespaces = Command::new("bwrap") + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/lib", + "/lib", + "--symlink", + "usr/lib64", + "/lib64", + "--", + "/usr/bin/true", + ]) + .status() + .is_ok_and(|status| status.success()); + let available = manager_socket && scope && namespaces; + if std::env::var_os("TINYBOX_REQUIRE_NATIVE_RESOURCE_TESTS").is_some() { + assert!( + available, + "required native resource test environment is unavailable: manager={manager_socket}, systemd-scope={scope}, namespaces={namespaces}" + ); + } + available +} + +#[cfg(target_os = "linux")] +fn pid_namespace_contains_process(pid: i32, namespace_inode: u64) -> std::io::Result { + for entry in std::fs::read_dir("/proc")? { + let Ok(entry) = entry else { continue }; + if !entry + .file_name() + .to_string_lossy() + .bytes() + .all(|byte| byte.is_ascii_digit()) + { + continue; + } + let Ok(status) = std::fs::read_to_string(entry.path().join("status")) else { + continue; + }; + let Ok(namespaces) = std::fs::read_link(entry.path().join("ns/pid")) else { + continue; + }; + let same_pid = status + .lines() + .find_map(|line| line.strip_prefix("NSpid:")) + .and_then(|pids| pids.split_whitespace().last()) + .and_then(|pid| pid.parse::().ok()) + == Some(pid); + let same_namespace = namespaces + .to_string_lossy() + .strip_prefix("pid:[") + .and_then(|inode| inode.strip_suffix(']')) + .and_then(|inode| inode.parse::().ok()) + == Some(namespace_inode); + if same_pid && same_namespace { + return Ok(true); + } + } + Ok(false) +} + #[cfg(unix)] #[test] fn requested_native_resource_limits_are_refused_before_spawn() @@ -115,6 +206,798 @@ fn requested_native_resource_limits_are_refused_before_spawn() assert!(prepare(root.path(), &config, &command, &backend).is_err()); Ok(()) } + +#[cfg(not(target_os = "linux"))] +#[test] +fn contract_21_resource_requests_refuse_before_spawn_on_non_linux() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let mut command = tinybox_core::ExecRequest::new(["target-program"]); + command.cwd = Some(root.path().into()); + let original = command.clone(); + let request = NativeJailRequest { + jail: config(), + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: None, + pids_max: None, + disk_bytes: None, + }), + }; + + let error = collector_with_resources(root.path(), &request, &mut command) + .err() + .ok_or("non-Linux resource requests must refuse before target launch")?; + assert_eq!(error.wire_name(), tinybox_bus::UNSUPPORTED_OPERATION); + assert_eq!(command.argv, original.argv); + assert_eq!(command.cwd, original.cwd); + assert_eq!(command.env, original.env); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn resource_backend_consumes_one_validated_launcher_plan() +-> std::result::Result<(), Box> { + use std::process::Command; + + let backend = super::NativeResourceBackend::new(); + assert_eq!(backend.isolation(), tinybox_core::IsolationLevel::Kernel); + let jail = tinybox_jail::Jail::new(std::env::temp_dir(), "resource-plan-test"); + assert!(backend.spawn(&jail, Command::new("true")).is_err()); + + let backend = super::NativeResourceBackend::new(); + let request = tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: None, + pids_max: None, + disk_bytes: None, + }; + backend.bind_resources(request)?; + assert!(backend.bind_resources(request).is_err()); + backend.bind(&["/bin/true".into()])?; + assert!(backend.bind(&["/bin/false".into()]).is_err()); + let command = Command::new("/bin/false"); + assert!(backend.spawn(&jail, command).is_err()); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn legacy_resource_limits_still_fail_closed_on_linux() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.path().into()); + let backend = tinybox_jail::LandlockBackend::new(); + if !backend.is_available() { + return Ok(()); + } + + // Contract 1.22's all-dimensions request remains hard and unsupported by + // the Landlock-only path. The new 2.1 subset request uses a separate API. + let mut config = config(); + config.resources = Some(tinybox_bus::ResourceLimits { + cpu_millis: 100, + memory_bytes: 64 * 1024 * 1024, + pids_max: 16, + disk_bytes: 1024, + }); + + assert!( + prepare(root.path(), &config, &command, &backend).is_err(), + "legacy Linux native jail must refuse the unsupported all-resource request" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn resource_aware_namespace_jail_preserves_grants_and_blocks_manager_escape() +-> std::result::Result<(), Box> { + use std::{ + io::Write, + process::{Command, Stdio}, + }; + + if !native_resource_runtime_available() { + eprintln!("skipped: rootless systemd scope and bubblewrap namespaces are unavailable"); + return Ok(()); + } + let root = tempfile::tempdir()?; + let fixture = native_escape_fixture(root.path())?; + + let probe = Command::new("systemd-run") + .args([ + "--user", + "--scope", + "--quiet", + "--property=CPUQuota=0.1%", + "--property=MemoryMax=67108864", + "--property=MemorySwapMax=0", + "--property=TasksMax=16", + "--", + "/bin/true", + ]) + .status()?; + if !probe.success() { + eprintln!("skipped: user systemd scope constraints are unavailable"); + return Ok(()); + } + + let (request, mut command) = resource_escape_command( + &fixture.workspace, + &fixture.denied_inside, + &fixture.bash_env, + &fixture.manager_alias, + fixture.inherited_fd, + fixture.inherited_inode, + &fixture.outside, + ); + let backend = resource_backend(); + let jail = prepare_with_resources( + root.path().join("workspace").as_path(), + &request, + &mut command, + backend.as_ref(), + )?; + let mut child_command = Command::new(command.argv.first().ok_or("missing launcher")?); + child_command + .args(command.argv.iter().skip(1)) + .env_clear() + .envs(&command.env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = tinybox_jail::spawn_captured_with(backend.as_ref(), &jail, child_command)?; + let mut ready = String::new(); + std::io::BufRead::read_line( + &mut std::io::BufReader::new(child.stdout.as_mut().ok_or("missing stdout pipe")?), + &mut ready, + )?; + if ready != "READY\n" { + let output = child.wait_with_output()?; + return Err(format!( + "launcher did not complete resource probes: status {:?}, stdout {}, stderr {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + assert_resource_cgroup(&command.argv, &mut child)?; + child + .stdin + .as_mut() + .ok_or("missing stdin pipe")? + .write_all(b"continue\n")?; + let output = child.wait_with_output()?; + assert!( + output.status.success(), + "launcher failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std::fs::read(fixture.workspace.join("control"))?, + b"allowed" + ); + assert_eq!( + std::fs::read(fixture.workspace.join("from-bash-env"))?, + b"startup" + ); + assert!( + !fixture.outside.exists(), + "jail wrote outside its granted roots" + ); + let _ = std::fs::remove_file(fixture.outside); + Ok(()) +} + +#[cfg(target_os = "linux")] +struct NativeEscapeFixture { + workspace: std::path::PathBuf, + denied_inside: std::path::PathBuf, + manager_alias: std::path::PathBuf, + outside: std::path::PathBuf, + bash_env: std::path::PathBuf, + _inherited_manager: std::os::unix::net::UnixStream, + inherited_fd: i32, + inherited_inode: u64, +} + +#[cfg(target_os = "linux")] +fn native_escape_fixture( + root: &std::path::Path, +) -> std::result::Result> { + use std::os::{ + fd::AsRawFd, + unix::fs::{FileTypeExt, MetadataExt}, + unix::net::UnixStream, + }; + + let workspace = root.join("workspace"); + std::fs::create_dir_all(workspace.join("hidden"))?; + std::fs::write(workspace.join("hidden/secret"), "secret")?; + let denied_inside = workspace.join("denied-inside"); + std::fs::create_dir_all(&denied_inside)?; + std::fs::write(denied_inside.join("secret"), "secret")?; + let address = std::env::var("DBUS_SESSION_BUS_ADDRESS")?; + let socket = address + .strip_prefix("unix:path=") + .ok_or("filesystem DBus manager socket is required for this escape test")?; + let socket = std::path::PathBuf::from(socket); + assert!(socket.metadata()?.file_type().is_socket()); + let inherited_manager = UnixStream::connect(&socket)?; + let inherited_fd = inherited_manager.as_raw_fd(); + let flags = nix::fcntl::fcntl(&inherited_manager, nix::fcntl::FcntlArg::F_GETFD)?; + assert_ne!(flags & nix::libc::FD_CLOEXEC, 0); + nix::fcntl::fcntl( + &inherited_manager, + nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::empty()), + )?; + let inherited_inode = std::fs::metadata(format!("/proc/self/fd/{inherited_fd}"))?.ino(); + let manager_alias = workspace.join("manager-socket-alias"); + std::os::unix::fs::symlink(socket, &manager_alias)?; + let outside = std::env::temp_dir().join(format!("tinybox-escape-proof-{}", std::process::id())); + let _ = std::fs::remove_file(&outside); + let bash_env = workspace.join("startup.sh"); + std::fs::write( + &bash_env, + format!( + "printf startup > {}\n", + shell_quote(&workspace.join("from-bash-env").display().to_string()) + ), + )?; + Ok(NativeEscapeFixture { + workspace, + denied_inside, + manager_alias, + outside, + bash_env, + _inherited_manager: inherited_manager, + inherited_fd, + inherited_inode, + }) +} + +#[cfg(target_os = "linux")] +fn resource_escape_command( + workspace: &std::path::Path, + denied_inside: &std::path::Path, + bash_env: &std::path::Path, + manager_alias: &std::path::Path, + inherited_fd: i32, + inherited_inode: u64, + outside: &std::path::Path, +) -> (tinybox_bus::NativeJailRequest, tinybox_core::ExecRequest) { + let mut jail = config(); + jail.allow_net = false; + jail.allow_subprocess = true; + jail.require_process_isolation = true; + jail.deny_read = vec![denied_inside.display().to_string()]; + let request = tinybox_bus::NativeJailRequest { + jail, + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: None, + }), + }; + let script = format!( + "printf allowed > {}; test ! -e {} || exit 41; test ! -r {} || exit 42; python3 -c 'data=bytearray(8388608); data[0]=1' || exit 45; python3 -c {} || exit 44; python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX)\ntry: s.connect(sys.argv[1])\nexcept OSError: sys.exit(0)\nsys.exit(1)' {} || exit 46; python3 -c 'import os,sys; fd=int(os.environ[\"TINYBOX_MANAGER_FD\"]); expected=int(os.environ[\"TINYBOX_MANAGER_INODE\"])\ntry: actual=os.stat(f\"/proc/self/fd/{{fd}}\").st_ino\nexcept OSError: sys.exit(0)\nsys.exit(1 if actual==expected else 0)' || exit 47; if systemd-run --user --scope --quiet -- /usr/bin/touch {} 2>/dev/null; then exit 43; fi; printf 'READY\\n'; read -r _; exit 0", + shell_quote(&workspace.join("control").display().to_string()), + shell_quote(&denied_inside.join("secret").display().to_string()), + shell_quote(&denied_inside.join("secret").display().to_string()), + shell_quote( + "import errno,os,signal\nchildren=[]\nblocked=False\ntry:\n for _ in range(64):\n pid=os.fork()\n if pid==0:\n while True: os.pause()\n children.append(pid)\nexcept OSError as e:\n blocked=e.errno==errno.EAGAIN\nfor pid in children: os.kill(pid,signal.SIGKILL)\nfor pid in children: os.waitpid(pid,0)\nassert blocked\n" + ), + shell_quote(&manager_alias.display().to_string()), + shell_quote(&outside.display().to_string()), + ); + let mut command = tinybox_core::ExecRequest::new(["/bin/bash", "-c", &script]); + command.cwd = Some(workspace.into()); + command + .env + .insert("BASH_ENV".into(), bash_env.display().to_string()); + command + .env + .insert("TINYBOX_MANAGER_FD".into(), inherited_fd.to_string()); + command + .env + .insert("TINYBOX_MANAGER_INODE".into(), inherited_inode.to_string()); + (request, command) +} + +#[cfg(target_os = "linux")] +fn assert_resource_cgroup( + argv: &[String], + child: &mut std::process::Child, +) -> std::result::Result<(), Box> { + let unit = argv + .iter() + .find_map(|part| part.strip_prefix("--unit=")) + .ok_or("launcher has no unique transient-scope unit")?; + let control_group = std::process::Command::new("systemctl") + .args(["--user", "show", "--property=ControlGroup", "--value", unit]) + .output()?; + assert!( + control_group.status.success(), + "cannot inspect transient scope" + ); + let control_group = String::from_utf8(control_group.stdout)?; + let cgroup = + std::path::Path::new("/sys/fs/cgroup").join(control_group.trim_start_matches('/').trim()); + assert_eq!( + std::fs::read_to_string(cgroup.join("memory.max"))?.trim(), + "67108864" + ); + assert_eq!( + std::fs::read_to_string(cgroup.join("memory.swap.max"))?.trim(), + "0" + ); + assert_eq!( + std::fs::read_to_string(cgroup.join("pids.max"))?.trim(), + "16" + ); + let pids_events = std::fs::read_to_string(cgroup.join("pids.events"))?; + let max_events: u64 = pids_events + .lines() + .find_map(|line| line.strip_prefix("max ")) + .ok_or("missing pids max event counter")? + .parse()?; + assert!(max_events > 0, "fork fixture never hit the pids limit"); + let cpu_max = std::fs::read_to_string(cgroup.join("cpu.max"))?; + let mut cpu_max = cpu_max.split_whitespace(); + let quota: u64 = cpu_max.next().ok_or("missing CPU quota")?.parse()?; + let period: u64 = cpu_max.next().ok_or("missing CPU period")?.parse()?; + assert!( + quota > 0 && quota <= period, + "CPU quota exceeded request: {quota}/{period}" + ); + let _ = child.try_wait()?; + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn native_memory_limit_kills_a_workload_that_touches_past_its_budget() +-> std::result::Result<(), Box> { + use std::{ + io::Write, + process::{Command, Stdio}, + }; + + if !native_resource_runtime_available() { + eprintln!("skipped: rootless systemd scope and bubblewrap namespaces are unavailable"); + return Ok(()); + } + if !Command::new("python3").arg("--version").status()?.success() { + eprintln!("skipped: python3 is unavailable for the memory pressure fixture"); + return Ok(()); + } + let probe = Command::new("systemd-run") + .args([ + "--user", + "--scope", + "--quiet", + "--property=MemoryMax=67108864", + "--property=MemorySwapMax=0", + "--property=CPUQuota=100%", + "--property=TasksMax=16", + "--", + "/bin/true", + ]) + .status()?; + if !probe.success() { + eprintln!("skipped: user systemd scope constraints are unavailable"); + return Ok(()); + } + + let root = tempfile::tempdir()?; + let mut jail_config = config(); + jail_config.allow_subprocess = true; + jail_config.require_process_isolation = true; + let request = tinybox_bus::NativeJailRequest { + jail: jail_config, + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: None, + }), + }; + let script = format!( + "python3 -c {}; status=$?; printf '%s' \"$status\" > {}; printf 'READY\\n'; read -r _; exit 0", + shell_quote( + "data=bytearray(256*1024*1024)\nfor i in range(0,len(data),4096): data[i]=1\nprint('allocation survived')" + ), + shell_quote(&root.path().join("python-status").display().to_string()) + ); + let mut command = tinybox_core::ExecRequest::new(["/bin/bash", "-c", &script]); + command.cwd = Some(root.path().into()); + let backend = resource_backend(); + let jail = prepare_with_resources(root.path(), &request, &mut command, backend.as_ref())?; + let mut launcher = Command::new(command.argv.first().ok_or("missing launcher")?); + launcher + .args(command.argv.iter().skip(1)) + .env_clear() + .envs(&command.env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = tinybox_jail::spawn_captured_with(backend.as_ref(), &jail, launcher)?; + let mut ready = String::new(); + std::io::BufRead::read_line( + &mut std::io::BufReader::new(child.stdout.as_mut().ok_or("missing stdout pipe")?), + &mut ready, + )?; + assert_eq!(ready, "READY\n", "memory pressure workload did not finish"); + let cgroup = active_scope_cgroup(&command.argv, &mut child)?; + let memory_events = std::fs::read_to_string(cgroup.join("memory.events")).map_err(|error| { + std::io::Error::other(format!( + "cannot read {}: {error}", + cgroup.join("memory.events").display() + )) + })?; + let oom_kills: u64 = memory_events + .lines() + .find_map(|line| line.strip_prefix("oom_kill ")) + .ok_or("missing memory OOM counter")? + .parse()?; + assert!(oom_kills > 0, "memory overrun did not increment oom_kill"); + assert_eq!( + std::fs::read_to_string(root.path().join("python-status"))?, + "137" + ); + child + .stdin + .as_mut() + .ok_or("missing stdin pipe")? + .write_all(b"continue\n")?; + let output = child.wait_with_output()?; + assert!( + output.status.success() || output.status.code() == Some(137), + "OOM should terminate the workload with success or SIGKILL status, got {:?}", + output.status + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn active_scope_cgroup( + argv: &[String], + child: &mut std::process::Child, +) -> std::io::Result { + let unit = argv + .iter() + .find_map(|part| part.strip_prefix("--unit=")) + .ok_or_else(|| std::io::Error::other("launcher has no transient scope unit"))?; + let control_group = std::process::Command::new("systemctl") + .args(["--user", "show", "--property=ControlGroup", "--value", unit]) + .output()?; + assert!(control_group.status.success()); + let control_group = String::from_utf8(control_group.stdout) + .map_err(|error| std::io::Error::other(error.to_string()))?; + assert!( + !control_group.trim().is_empty(), + "scope {unit} has no cgroup path; systemd-run status is {:?}", + child.try_wait()? + ); + Ok(std::path::Path::new("/sys/fs/cgroup").join(control_group.trim_start_matches('/').trim())) +} + +#[cfg(target_os = "linux")] +#[test] +fn fractional_cpu_limit_throttles_the_live_native_scope() +-> std::result::Result<(), Box> { + use std::{ + io::BufRead, + process::{Command, Stdio}, + time::{Duration, Instant}, + }; + + if !native_resource_runtime_available() { + eprintln!("skipped: rootless systemd scope and bubblewrap namespaces are unavailable"); + return Ok(()); + } + let root = tempfile::tempdir()?; + let release = root.path().join("release-cpu-loop"); + let script = format!( + "printf 'READY\\n'; while [ ! -e {} ]; do :; done; printf 'DONE\\n'", + shell_quote(&release.display().to_string()) + ); + let mut config = config(); + config.allow_subprocess = true; + config.require_process_isolation = true; + let request = tinybox_bus::NativeJailRequest { + jail: config, + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: None, + }), + }; + let mut command = tinybox_core::ExecRequest::new(["/bin/bash", "-c", &script]); + command.cwd = Some(root.path().into()); + let backend = resource_backend(); + let jail = prepare_with_resources(root.path(), &request, &mut command, backend.as_ref())?; + let mut launcher = Command::new(command.argv.first().ok_or("missing launcher")?); + launcher + .args(command.argv.iter().skip(1)) + .env_clear() + .envs(&command.env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = tinybox_jail::spawn_captured_with(backend.as_ref(), &jail, launcher)?; + let mut ready = String::new(); + BufRead::read_line( + &mut std::io::BufReader::new(child.stdout.as_mut().ok_or("missing stdout pipe")?), + &mut ready, + )?; + assert_eq!(ready, "READY\n"); + let unit = command + .argv + .iter() + .find_map(|part| part.strip_prefix("--unit=")) + .ok_or("launcher has no transient scope unit")?; + let control_group = Command::new("systemctl") + .args(["--user", "show", "--property=ControlGroup", "--value", unit]) + .output()?; + assert!(control_group.status.success()); + let cgroup = std::path::Path::new("/sys/fs/cgroup").join( + String::from_utf8(control_group.stdout)? + .trim_start_matches('/') + .trim(), + ); + let cpu_max = std::fs::read_to_string(cgroup.join("cpu.max"))?; + let mut cpu_max = cpu_max.split_whitespace(); + let quota: u64 = cpu_max.next().ok_or("missing CPU quota")?.parse()?; + let period: u64 = cpu_max.next().ok_or("missing CPU period")?.parse()?; + assert!( + quota > 0 && quota * 1000 <= period, + "CPU ratio exceeds 1 milli-CPU: {quota}/{period}" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let throttled = loop { + let stats = std::fs::read_to_string(cgroup.join("cpu.stat"))?; + let count: u64 = stats + .lines() + .find_map(|line| line.strip_prefix("nr_throttled ")) + .ok_or("missing CPU throttle counter")? + .parse()?; + if count > 0 { + break true; + } + if Instant::now() >= deadline { + break false; + } + std::thread::sleep(Duration::from_millis(20)); + }; + assert!( + throttled, + "CPU-bound workload was never throttled by cpu.max" + ); + std::fs::write(&release, b"release")?; + let output = child.wait_with_output()?; + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("DONE")); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn native_scope_exit_kills_double_forked_setsid_descendants() +-> std::result::Result<(), Box> { + use std::{ + io::Write, + process::{Command, Stdio}, + time::{Duration, Instant}, + }; + + if !native_resource_runtime_available() { + eprintln!("skipped: rootless systemd scope and bubblewrap namespaces are unavailable"); + return Ok(()); + } + let root = tempfile::tempdir()?; + let pid_file = root.path().join("detached.pid"); + let python = format!( + "import os,time\nfirst=os.fork()\nif first == 0:\n os.setsid()\n second=os.fork()\n if second == 0:\n with open({},'w') as pidfile: pidfile.write(f'{{os.getpid()}} {{os.stat(\"/proc/self/ns/pid\").st_ino}}')\n while True: time.sleep(1)\n os._exit(0)\nos.waitpid(first,0)\nwhile not os.path.exists({}) or os.path.getsize({}) == 0: time.sleep(0.01)\npid=int(open({}).read().split()[0])\nos.kill(pid,0)", + shell_quote(&pid_file.display().to_string()), + shell_quote(&pid_file.display().to_string()), + shell_quote(&pid_file.display().to_string()), + shell_quote(&pid_file.display().to_string()) + ); + let script = format!( + "python3 -c {}; status=$?; printf 'READY\\n'; read -r _; exit \"$status\"", + shell_quote(&python) + ); + let mut config = config(); + config.allow_subprocess = true; + config.require_process_isolation = true; + let request = tinybox_bus::NativeJailRequest { + jail: config, + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: None, + }), + }; + let mut command = tinybox_core::ExecRequest::new(["/bin/bash", "-c", &script]); + command.cwd = Some(root.path().into()); + let backend = resource_backend(); + let jail = prepare_with_resources(root.path(), &request, &mut command, backend.as_ref())?; + let mut launcher = Command::new(command.argv.first().ok_or("missing launcher")?); + launcher + .args(command.argv.iter().skip(1)) + .env_clear() + .envs(&command.env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = tinybox_jail::spawn_captured_with(backend.as_ref(), &jail, launcher)?; + let mut ready = String::new(); + std::io::BufRead::read_line( + &mut std::io::BufReader::new(child.stdout.as_mut().ok_or("missing stdout pipe")?), + &mut ready, + )?; + assert_eq!(ready, "READY\n"); + let identity = std::fs::read_to_string(&pid_file)?; + let mut identity = identity.split_whitespace(); + let detached: i32 = identity.next().ok_or("missing detached PID")?.parse()?; + let pid_namespace: u64 = identity + .next() + .ok_or("missing PID namespace inode")? + .parse()?; + assert!( + pid_namespace_contains_process(detached, pid_namespace)?, + "fixture did not expose its detached process in the host proc view" + ); + child + .stdin + .as_mut() + .ok_or("missing release pipe")? + .write_all(b"continue\n")?; + let output = child.wait_with_output()?; + assert!( + output.status.success(), + "jail supervisor failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let still_alive = pid_namespace_contains_process(detached, pid_namespace)?; + if !still_alive { + break; + } + if Instant::now() >= deadline { + return Err(format!("detached descendant {detached} survived scope exit").into()); + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn native_cancellation_kills_detached_descendants_and_preserves_stream_events() +-> std::result::Result<(), Box> { + use std::{process::Command, time::Duration}; + + if !native_resource_runtime_available() { + eprintln!("skipped: rootless systemd scope and bubblewrap namespaces are unavailable"); + return Ok(()); + } + let root = tempfile::tempdir()?; + let pid_file = root.path().join("cancelled-descendant"); + let python = format!( + "import os,time,sys\nfirst=os.fork()\nif first == 0:\n os.setsid()\n second=os.fork()\n if second == 0:\n with open({},'w') as f: f.write(f'{{os.getpid()}} {{os.stat(\"/proc/self/ns/pid\").st_ino}}')\n while True: time.sleep(1)\n os._exit(0)\nos.waitpid(first,0)\nwhile not os.path.exists({}) or os.path.getsize({}) == 0: time.sleep(0.01)\nprint('stdout-ready',flush=True)\nprint('stderr-ready',file=sys.stderr,flush=True)\ntime.sleep(600)", + shell_quote(&pid_file.display().to_string()), + shell_quote(&pid_file.display().to_string()), + shell_quote(&pid_file.display().to_string()) + ); + let mut jail_config = config(); + jail_config.allow_subprocess = true; + jail_config.require_process_isolation = true; + let request = tinybox_bus::NativeJailRequest { + jail: jail_config, + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: None, + }), + }; + let mut exec = tinybox_core::ExecRequest::new(["python3", "-c", &python]); + exec.cwd = Some(root.path().into()); + let backend = resource_backend(); + let jail = prepare_with_resources(root.path(), &request, &mut exec, backend.as_ref())?; + let host = Arc::new(tinybox_host::JailLocalHost::with_backend( + jail, + backend, + tinybox_bus::MAX_OUTPUT_BYTES, + )?); + let mut command = Command::new(exec.argv.first().ok_or("missing target")?); + command + .args(exec.argv.iter().skip(1)) + .env_clear() + .envs(exec.env); + let journal = Arc::new(super::super::output::OutputJournal::default()); + let runner = host.clone(); + let observer = journal.clone(); + let task = + tokio::spawn(async move { runner.run_command_observed(command, None, observer).await }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut ready = false; + while tokio::time::Instant::now() < deadline { + let output = journal.read(0)?; + let stream_contains = |stream, marker: &[u8]| { + let bytes: Vec<_> = output + .chunks + .iter() + .filter(|chunk| chunk.stream == stream) + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect(); + bytes.windows(marker.len()).any(|window| window == marker) + }; + let stdout = stream_contains(tinybox_bus::OutputStream::Stdout, b"stdout-ready"); + let stderr = stream_contains(tinybox_bus::OutputStream::Stderr, b"stderr-ready"); + if stdout && stderr && pid_file.metadata().is_ok() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + ready, + "native exec did not deliver both output streams and descendant identity: {:?}", + journal.read(0)?.chunks + ); + let identity = std::fs::read_to_string(&pid_file)?; + let mut identity = identity.split_whitespace(); + let detached: i32 = identity.next().ok_or("missing detached PID")?.parse()?; + let pid_namespace: u64 = identity.next().ok_or("missing PID namespace")?.parse()?; + assert!( + pid_namespace_contains_process(detached, pid_namespace)?, + "fixture did not expose its detached process in the host proc view" + ); + journal.cancel(); + assert!( + task.await?.is_err(), + "cancelled native command unexpectedly succeeded" + ); + assert!( + !host.has_pending_cleanup(), + "cancel did not acknowledge child cleanup" + ); + + assert_process_disappears(detached, pid_namespace, "cancelled descendant") +} + +#[cfg(target_os = "linux")] +fn assert_process_disappears( + pid: i32, + namespace_inode: u64, + description: &str, +) -> std::result::Result<(), Box> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + if !pid_namespace_contains_process(pid, namespace_inode)? { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + return Err(format!("{description} {pid} survived cleanup").into()); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + #[test] fn native_grants_reject_relative_parent_null_and_missing_paths() { assert!(grants(&["/".repeat(tinybox_bus::MAX_FILE_PATH_BYTES + 1)]).is_err()); diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index e56b5522..b6282b95 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -8,11 +8,17 @@ use tinybox_bus::{ExecutionFailure, OutputBatch}; pub(super) const MAX_STREAM_EXECUTIONS: usize = 32; +#[derive(Debug, Clone, PartialEq, Eq)] +enum NativeJailRequest { + Legacy(tinybox_bus::NativeJailConfig), + Resources(tinybox_bus::NativeJailRequest), +} + pub(super) struct StreamExecution { request: SpawnRequest, shell: Option, platform_shell: Option, - jail: Option, + jail: Option, journal: Arc, task: Mutex>>, collector: Collector, @@ -128,8 +134,8 @@ impl Collector { } fn select_collector( entry: &super::Resource, - jail: Option<&tinybox_bus::NativeJailConfig>, - resolved: &tinybox_core::ExecRequest, + jail: Option<&NativeJailRequest>, + resolved: &mut tinybox_core::ExecRequest, ) -> Result { Ok(if let Some(config) = jail { if !entry.native_host || entry.local_sandbox.is_none() { @@ -144,12 +150,17 @@ fn select_collector( "native jail requires a directory resource", ) })?; - Collector::Jailed(super::super::native_jail::collector( - root, - config, - resolved, - tinybox_jail::default_backend(), - )?) + Collector::Jailed(match config { + NativeJailRequest::Legacy(config) => super::super::native_jail::collector( + root, + config, + resolved, + tinybox_jail::default_backend(), + )?, + NativeJailRequest::Resources(config) => { + super::super::native_jail::collector_with_resources(root, config, resolved)? + } + }) } else { let native = Arc::new(tinybox_host::LimitedLocalHost::new( tinybox_bus::MAX_OUTPUT_BYTES, @@ -181,9 +192,9 @@ fn require_owned_collector(entry: &super::Resource) -> Result<()> { } fn select_execution_collector( entry: &super::Resource, - jail: Option<&tinybox_bus::NativeJailConfig>, + jail: Option<&NativeJailRequest>, shell: Option<&tinybox_bus::ShellExecRequest>, - resolved: &tinybox_core::ExecRequest, + resolved: &mut tinybox_core::ExecRequest, ) -> Result { Ok(if let Some(shell) = shell { if !entry.native_host || entry.local_sandbox.is_none() { @@ -206,7 +217,7 @@ fn select_execution_collector( fn prepare_execution( entry: &super::Resource, request: &SpawnRequest, - jail: Option<&tinybox_bus::NativeJailConfig>, + jail: Option<&NativeJailRequest>, shell: Option<&tinybox_bus::ShellExecRequest>, platform_shell: Option<&tinybox_bus::SelectShellRequest>, ) -> Result<(tinybox_core::ExecRequest, CollectorPreparation)> { @@ -233,7 +244,7 @@ fn prepare_execution( // Resolve resource cwd and environment; the shell builder supplies argv. native_request.argv = vec!["shell".into()]; } - let resolved = if let Some(sandbox) = &entry.local_sandbox { + let mut resolved = if let Some(sandbox) = &entry.local_sandbox { sandbox .resolve_command(&entry.id, &native_request) .map_err(|error| backend_error(&error))? @@ -252,7 +263,7 @@ fn prepare_execution( shell.clone(), )) } else { - select_execution_collector(entry, jail, shell, &resolved)? + select_execution_collector(entry, jail, shell, &mut resolved)? }; Ok((resolved, collector)) } @@ -260,7 +271,7 @@ impl StreamExecution { fn check_replay( &self, request: &SpawnRequest, - jail: Option<&tinybox_bus::NativeJailConfig>, + jail: Option<&NativeJailRequest>, shell: Option<&tinybox_bus::ShellExecRequest>, platform_shell: Option<&tinybox_bus::SelectShellRequest>, ) -> Result<()> { @@ -284,7 +295,7 @@ impl StreamExecution { request: SpawnRequest, resolved: tinybox_core::ExecRequest, collector: Collector, - jail: Option, + jail: Option, shell: Option, platform_shell: Option, ) -> Self { @@ -407,9 +418,22 @@ impl Resources { request: SpawnRequest, config: tinybox_bus::NativeJailConfig, ) -> Result { - self.start_with_jail(request, Some(config), None, None) + self.start_with_jail(request, Some(NativeJailRequest::Legacy(config)), None, None) .await } + pub(in crate::tinybus_module) async fn start_jailed_exec_with_resources( + &self, + request: SpawnRequest, + config: tinybox_bus::NativeJailRequest, + ) -> Result { + self.start_with_jail( + request, + Some(NativeJailRequest::Resources(config)), + None, + None, + ) + .await + } pub(in crate::tinybus_module) async fn start_shell_exec( &self, shell: tinybox_bus::ShellExecRequest, @@ -424,8 +448,13 @@ impl Resources { stdin: shell.stdin.clone(), }, }; - self.start_with_jail(request, shell.jail.clone(), Some(shell), None) - .await + self.start_with_jail( + request, + shell.jail.clone().map(NativeJailRequest::Legacy), + Some(shell), + None, + ) + .await } pub(in crate::tinybus_module) async fn start_platform_shell_exec( &self, @@ -443,7 +472,7 @@ impl Resources { async fn start_with_jail( &self, request: SpawnRequest, - jail: Option, + jail: Option, shell: Option, platform_shell: Option, ) -> Result { @@ -459,7 +488,7 @@ impl Resources { async fn start_with_preparation( &self, request: SpawnRequest, - jail: Option, + jail: Option, shell: Option, platform_shell: Option, prepare: impl FnOnce(CollectorPreparation) -> Fut, diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index a60d02bf..a11fe017 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1311,3 +1311,272 @@ async fn concurrent_preparations_share_a_bounded_admission_budget() ); Ok(()) } + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resource_aware_bus_exec_cancellation_reports_events_and_reaps_descendants() +-> std::result::Result<(), Box> { + if !native_resource_bus_available() { + eprintln!("skipped: rootless systemd scope and bubblewrap namespaces are unavailable"); + return Ok(()); + } + let root = tempfile::tempdir()?; + let resources = Resources::test_resources(); + let resource = resources.reserve(ReserveRequest::Resource).await?; + resources + .create(CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: Workspace::Directory(root.path().display().to_string()), + ..Default::default() + }) + .await?; + let python = detached_descendant_script(); + let cancel_file = root.path().join("cancelled-descendant"); + let cancelled = + start_resource_descendant(&resources, &resource, root.path(), &cancel_file, &python) + .await?; + let (cancel_pid, cancel_ns) = + wait_for_resource_descendant(&resources, &cancelled, &cancel_file, true).await?; + assert!( + process_in_namespace(cancel_pid, cancel_ns)?, + "fixture descendant was not visible before cancellation" + ); + cancel_resource_descendant(&resources, &cancelled, cancel_pid, cancel_ns).await?; + resources.release_output(&cancelled).await?; + + let destroy_file = root.path().join("destroyed-descendant"); + let destroyed = + start_resource_descendant(&resources, &resource, root.path(), &destroy_file, &python) + .await?; + let (destroy_pid, destroy_ns) = + wait_for_resource_descendant(&resources, &destroyed, &destroy_file, false).await?; + assert!( + process_in_namespace(destroy_pid, destroy_ns)?, + "fixture descendant was not visible before destroy" + ); + resources.close(&resource).await?; + assert!( + !process_in_namespace(destroy_pid, destroy_ns)?, + "resource destroy left a detached namespace process alive" + ); + resources.shutdown().await?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn native_resource_bus_available() -> bool { + use std::{os::unix::fs::FileTypeExt, process::Command}; + let bus = std::env::var("DBUS_SESSION_BUS_ADDRESS") + .ok() + .and_then(|address| address.strip_prefix("unix:path=").map(str::to_owned)) + .is_some_and(|path| { + std::fs::metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket()) + }); + let scope = Command::new("systemd-run") + .args([ + "--user", + "--scope", + "--quiet", + "--property=MemoryMax=67108864", + "--property=MemorySwapMax=0", + "--property=OOMPolicy=continue", + "--property=CPUQuota=100%", + "--property=TasksMax=16", + "--", + "/bin/true", + ]) + .status() + .is_ok_and(|status| status.success()); + let namespaces = Command::new("bwrap") + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/lib", + "/lib", + "--symlink", + "usr/lib64", + "/lib64", + "--", + "/usr/bin/true", + ]) + .status() + .is_ok_and(|status| status.success()); + let available = bus && scope && namespaces; + if std::env::var_os("TINYBOX_REQUIRE_NATIVE_RESOURCE_TESTS").is_some() { + assert!(available, "required native resource runtime is unavailable"); + } + available +} + +#[cfg(target_os = "linux")] +fn detached_descendant_script() -> String { + "import os,time,sys\nfirst=os.fork()\nif first == 0:\n os.setsid()\n second=os.fork()\n if second == 0:\n with open(sys.argv[1],'w') as f: f.write(f'{os.getpid()} {os.stat(\"/proc/self/ns/pid\").st_ino}')\n while True: time.sleep(1)\n os._exit(0)\nos.waitpid(first,0)\nwhile not os.path.exists(sys.argv[1]) or os.path.getsize(sys.argv[1]) == 0: time.sleep(0.01)\nprint('stdout-ready',flush=True)\nprint('stderr-ready',file=sys.stderr,flush=True)\ntime.sleep(600)".into() +} + +#[cfg(target_os = "linux")] +async fn start_resource_descendant( + resources: &Resources, + resource: &ResourceId, + root: &std::path::Path, + pid_file: &std::path::Path, + python: &str, +) -> std::result::Result> { + let process_id = resources + .reserve(ReserveRequest::Process(resource.clone())) + .await?; + let process = ProcessRef { + resource: resource.clone(), + process: process_id.clone(), + }; + resources + .start_jailed_exec_with_resources( + SpawnRequest { + process: process_id, + command: ExecRequest { + resource: resource.clone(), + argv: vec![ + "python3".into(), + "-c".into(), + python.into(), + pid_file.display().to_string(), + ], + cwd: Some(root.display().to_string()), + env: std::collections::BTreeMap::default(), + stdin: None, + }, + }, + tinybox_bus::NativeJailRequest { + jail: tinybox_bus::NativeJailConfig { + read_only: vec![], + read_write: vec![], + allow_net: false, + allow_subprocess: true, + enforcement: tinybox_bus::JailEnforcement::Enforced, + require_process_isolation: true, + deny_read: vec![], + resources: None, + }, + resources: Some(tinybox_bus::NativeJailResourceRequest { + cpu_millis: Some(1000), + memory_bytes: Some(64 * 1024 * 1024), + pids_max: Some(16), + disk_bytes: None, + }), + }, + ) + .await?; + Ok(process) +} + +#[cfg(target_os = "linux")] +async fn wait_for_resource_descendant( + resources: &Resources, + process: &ProcessRef, + pid_file: &std::path::Path, + check_streams: bool, +) -> std::result::Result<(i32, u64), Box> { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let batch = resources.read_output(process, 0).await?; + let stream_has = |stream, marker: &[u8]| { + let bytes: Vec<_> = batch + .chunks + .iter() + .filter(|chunk| chunk.stream == stream) + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect(); + bytes.windows(marker.len()).any(|window| window == marker) + }; + let streams_ready = !check_streams + || (stream_has(tinybox_bus::OutputStream::Stdout, b"stdout-ready") + && stream_has(tinybox_bus::OutputStream::Stderr, b"stderr-ready")); + if streams_ready && std::fs::metadata(pid_file).is_ok() { + let identity = std::fs::read_to_string(pid_file)?; + let mut parts = identity.split_whitespace(); + return Ok(( + parts.next().ok_or("missing detached PID")?.parse()?, + parts.next().ok_or("missing PID namespace")?.parse()?, + )); + } + if batch.state != ExecutionState::Running || tokio::time::Instant::now() >= deadline { + return Err( + format!("native bus exec failed before descendant identity: {batch:?}").into(), + ); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } +} + +#[cfg(target_os = "linux")] +async fn cancel_resource_descendant( + resources: &Resources, + process: &ProcessRef, + pid: i32, + namespace: u64, +) -> std::result::Result<(), Box> { + resources.cancel(process).await?; + let cancelled = resources.read_output(process, 0).await?; + assert_eq!(cancelled.state, ExecutionState::Cancelled); + assert!( + cancelled + .chunks + .iter() + .any(|chunk| chunk.stream == tinybox_bus::OutputStream::Stdout) + && cancelled + .chunks + .iter() + .any(|chunk| chunk.stream == tinybox_bus::OutputStream::Stderr), + "cancellation must preserve both output streams" + ); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while process_in_namespace(pid, namespace)? { + if tokio::time::Instant::now() >= deadline { + return Err("bus cancellation left a detached namespace process alive".into()); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn process_in_namespace(pid: i32, namespace_inode: u64) -> std::io::Result { + for entry in std::fs::read_dir("/proc")? { + let Ok(entry) = entry else { continue }; + if !entry + .file_name() + .to_string_lossy() + .bytes() + .all(|byte| byte.is_ascii_digit()) + { + continue; + } + let Ok(status) = std::fs::read_to_string(entry.path().join("status")) else { + continue; + }; + let Ok(namespace) = std::fs::read_link(entry.path().join("ns/pid")) else { + continue; + }; + let same_pid = status + .lines() + .find_map(|line| line.strip_prefix("NSpid:")) + .and_then(|pids| pids.split_whitespace().last()) + .and_then(|pid| pid.parse::().ok()) + == Some(pid); + let same_namespace = namespace + .to_string_lossy() + .strip_prefix("pid:[") + .and_then(|inode| inode.strip_suffix(']')) + .and_then(|inode| inode.parse::().ok()) + == Some(namespace_inode); + if same_pid && same_namespace { + return Ok(true); + } + } + Ok(false) +} diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 812d5c11..00b8932b 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -1,5 +1,7 @@ //! Unconditional compiled-artifact admission and local grant discovery proof. +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use std::time::Duration; use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; @@ -17,15 +19,15 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() let mut staging = tempfile::Builder::new(); staging.prefix("admitted-module-"); #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - staging.permissions(std::fs::Permissions::from_mode(0o700)); - } + staging.permissions(std::fs::Permissions::from_mode(0o700)); let staged = staging.tempdir_in(std::env::temp_dir())?; #[cfg(windows)] secure_staging_directory(staged.path())?; - let staging_path = staged.path().canonicalize()?; - let artifact = staging_path.join( + // macOS exposes the temporary root through symlinked system paths such as + // /var. Native module admission walks every path component without + // following symlinks, so pass it the canonical private staging path. + let staging_root = staged.path().canonicalize()?; + let artifact = staging_root.join( built_artifact .file_name() .ok_or("artifact has no filename")?, diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index 2ca55923..3ff71a96 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -2,4 +2,8 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" cd "$root" +if [[ "${GITHUB_ACTIONS:-}" == true && "${RUNNER_OS:-}" == Linux ]]; then + source scripts/ci/prepare-rootless-native-resources.sh + export TINYBOX_REQUIRE_NATIVE_RESOURCE_TESTS=1 +fi .github/scripts/check-file-coverage.sh 90 coverage.json diff --git a/scripts/ci/prepare-rootless-native-resources.sh b/scripts/ci/prepare-rootless-native-resources.sh new file mode 100644 index 00000000..8579c78d --- /dev/null +++ b/scripts/ci/prepare-rootless-native-resources.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +sudo apt-get update +sudo apt-get install --yes bubblewrap dbus-user-session systemd + +uid="$(id -u)" +sudo loginctl enable-linger "$USER" +sudo systemctl start "user@${uid}.service" +runtime_dir="/run/user/${uid}" +export XDG_RUNTIME_DIR="$runtime_dir" +export DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus" +if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 +fi + +systemctl --user show-environment >/dev/null +systemd-run --user --scope --quiet --property=CPUQuota=10% -- /bin/true