diff --git a/.azure-pipelines/templates/Lint.Job.yml b/.azure-pipelines/templates/Lint.Job.yml index 7dd106131..8635ef964 100644 --- a/.azure-pipelines/templates/Lint.Job.yml +++ b/.azure-pipelines/templates/Lint.Job.yml @@ -29,7 +29,7 @@ parameters: # macOS-only crates (mxc_darwin, seatbelt_common) — the workspace # has Windows-only crates that do not compile on macOS, so we can't # use --all-features here. - clippyArgs: --locked --release -p mxc_darwin -p seatbelt_common + clippyArgs: --locked --release -p mxc_darwin -p seatbelt_common -p apple_container_common jobs: - ${{ each item in parameters.matrix }}: diff --git a/.github/workflows/Build.MacOS.Job.yml b/.github/workflows/Build.MacOS.Job.yml index ce97d3596..7e038bb28 100644 --- a/.github/workflows/Build.MacOS.Job.yml +++ b/.github/workflows/Build.MacOS.Job.yml @@ -43,7 +43,8 @@ jobs: # ships alongside mxc-exec-mac. - name: Build run: cargo build --locked --release --target aarch64-apple-darwin - -p mxc_darwin -p seatbelt_common -p wxc_common -p wxc_e2e_tests + -p mxc_darwin -p seatbelt_common -p apple_container_common + -p wxc_common -p wxc_e2e_tests -p unix_test_proxy # wxc_e2e_tests includes the Seatbelt executor characterization tests. @@ -52,7 +53,8 @@ jobs: # no elevation, so they run in this standard macOS job. - name: Test run: cargo test --locked --release --target aarch64-apple-darwin - -p mxc_darwin -p seatbelt_common -p wxc_common -p wxc_e2e_tests + -p mxc_darwin -p seatbelt_common -p apple_container_common + -p wxc_common -p wxc_e2e_tests - name: Verify artifact payload shell: bash diff --git a/.github/workflows/Lint.Job.yml b/.github/workflows/Lint.Job.yml index 6ca9abc63..6f4465c71 100644 --- a/.github/workflows/Lint.Job.yml +++ b/.github/workflows/Lint.Job.yml @@ -28,7 +28,7 @@ jobs: target: aarch64-apple-darwin working-directory: src # Workspace has Windows-only crates, so --all-features won't build on macOS. - clippy-args: --locked --release -p mxc_darwin -p seatbelt_common + clippy-args: --locked --release -p mxc_darwin -p seatbelt_common -p apple_container_common runs-on: ${{ matrix.runner }} defaults: run: diff --git a/src/Cargo.lock b/src/Cargo.lock index 43b7b63bd..38cd29b78 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -108,6 +108,7 @@ name = "apple_container_common" version = "0.7.0" dependencies = [ "getrandom 0.2.17", + "libc", "serde", "serde_json", "thiserror", diff --git a/src/backends/apple_container/common/Cargo.toml b/src/backends/apple_container/common/Cargo.toml index 3e0bc1d72..23f60744f 100644 --- a/src/backends/apple_container/common/Cargo.toml +++ b/src/backends/apple_container/common/Cargo.toml @@ -7,6 +7,7 @@ description = "Typed Apple Container management primitives for the MXC macOS bac [dependencies] getrandom = { workspace = true } +libc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/src/backends/apple_container/common/src/availability.rs b/src/backends/apple_container/common/src/availability.rs index 4238088e2..995250f95 100644 --- a/src/backends/apple_container/common/src/availability.rs +++ b/src/backends/apple_container/common/src/availability.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use thiserror::Error; use wxc_common::mxc_error::MxcError; -use crate::command::{CliArgument, CliCommand, CommandOutput, CommandRunner}; +use crate::command::{CliArgument, CliCommand, CommandOutput, CommandRunner, SystemCommandRunner}; /// Fixed path where Apple's installer places the Container CLI. pub const APPLE_CONTAINER_CLI_PATH: &str = "/usr/local/bin/container"; @@ -125,6 +125,46 @@ pub fn check_cli_installed() -> Result<(), AvailabilityError> { check_cli_presence(Path::new(APPLE_CONTAINER_CLI_PATH).is_file()) } +/// Probe the current host using the production bounded command runner. +pub fn probe() -> Result { + probe_with(&SystemCommandRunner, ¤t_host_info()) +} + +/// Whether the current host passes the full read-only availability probe. +pub fn is_available() -> bool { + probe().is_ok() +} + +fn current_host_info() -> HostInfo { + HostInfo::current(current_macos_major_version()) +} + +#[cfg(target_os = "macos")] +fn current_macos_major_version() -> Option { + let mut name = std::mem::MaybeUninit::::uninit(); + // SAFETY: `uname` initializes the supplied `utsname` on success. + if unsafe { libc::uname(name.as_mut_ptr()) } != 0 { + return None; + } + // SAFETY: `uname` succeeded, so every field is initialized and the release + // field is a NUL-terminated C string. + let name = unsafe { name.assume_init() }; + let release = unsafe { std::ffi::CStr::from_ptr(name.release.as_ptr()) }; + let darwin_major = release + .to_str() + .ok()? + .split('.') + .next()? + .parse::() + .ok()?; + darwin_major.checked_add(1) +} + +#[cfg(not(target_os = "macos"))] +fn current_macos_major_version() -> Option { + None +} + fn check_cli_presence(present: bool) -> Result<(), AvailabilityError> { if present { Ok(()) diff --git a/src/backends/apple_container/common/src/cli.rs b/src/backends/apple_container/common/src/cli.rs new file mode 100644 index 000000000..87bf69f5c --- /dev/null +++ b/src/backends/apple_container/common/src/cli.rs @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::BTreeMap; +use std::ffi::OsString; + +use serde::Deserialize; +use thiserror::Error; + +use crate::command::{CliArgument, CliCommand, CommandOutput, CommandRunner}; +use crate::plan::{MountAccess, NetworkPlan, RunPlan}; +use crate::resource::{OwnedResource, ResourceKind}; + +/// Trusted MXC guest-init image qualified for default-deny networking. +pub const NETWORK_BLOCK_INIT_IMAGE: &str = "local/mxc-loopback-init:0.2"; +pub const NETWORK_BLOCK_INIT_IMAGE_DIGEST: &str = + "sha256:a82bc45e6fee26927b9881150ca2d8d1b29969a306ba579e8b8887345d31dc2f"; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CliError { + #[error("{0}")] + Command(String), + #[error("{command} failed with status {status:?}: {stderr}")] + Exit { + command: String, + status: Option, + stderr: String, + }, + #[error("{resource} returned malformed JSON: {message}")] + MalformedJson { + resource: &'static str, + message: String, + }, + #[error("Apple Container returned {actual} {kind} records while inspecting {expected:?}")] + UnexpectedInspectResult { + kind: &'static str, + expected: String, + actual: usize, + }, + #[error("refusing to modify Apple {kind} {name:?}: MXC ownership labels do not match")] + OwnershipMismatch { kind: &'static str, name: String }, + #[error( + "qualified Apple Container init image {image:?} is not installed at digest {expected_digest}" + )] + InitImageMismatch { + image: &'static str, + expected_digest: &'static str, + }, +} + +#[derive(Debug, Deserialize)] +struct ContainerInspect { + id: String, + configuration: ContainerConfiguration, +} + +#[derive(Debug, Deserialize)] +struct ContainerConfiguration { + id: String, + labels: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct NetworkInspect { + id: String, + configuration: NetworkConfiguration, +} + +#[derive(Debug, Deserialize)] +struct NetworkConfiguration { + name: String, + labels: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct ImageInspect { + configuration: ImageConfiguration, +} + +#[derive(Debug, Deserialize)] +struct ImageConfiguration { + descriptor: ImageDescriptor, + name: String, +} + +#[derive(Debug, Deserialize)] +struct ImageDescriptor { + digest: String, +} + +/// Execute a management command and require a successful CLI exit. +pub fn run_checked( + runner: &dyn CommandRunner, + command: &CliCommand, +) -> Result { + let output = runner + .run(command) + .map_err(|error| CliError::Command(error.to_string()))?; + if output.success() { + Ok(output) + } else { + Err(CliError::Exit { + command: command.diagnostic(), + status: output.exit_code, + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } +} + +/// Build the isolated per-run network creation command. +pub fn create_network_command(resource: &OwnedResource) -> CliCommand { + debug_assert_eq!(resource.name.kind(), ResourceKind::Network); + let mut arguments = vec![ + CliArgument::literal("network"), + CliArgument::literal("create"), + CliArgument::literal("--internal"), + ]; + append_labels(&mut arguments, resource, "--label"); + arguments.push(CliArgument::literal(resource.name.as_str())); + CliCommand::new(arguments) +} + +/// Require the locally installed enforcement image to match the qualified digest. +pub fn verify_network_block_init_image(runner: &dyn CommandRunner) -> Result<(), CliError> { + let command = CliCommand::new([ + CliArgument::literal("image"), + CliArgument::literal("inspect"), + CliArgument::literal(NETWORK_BLOCK_INIT_IMAGE), + ]); + let output = run_checked(runner, &command)?; + let records: Vec = + serde_json::from_slice(&output.stdout).map_err(|error| CliError::MalformedJson { + resource: "init image inspect", + message: error.to_string(), + })?; + let Some(record) = records.first().filter(|_| records.len() == 1) else { + return Err(CliError::UnexpectedInspectResult { + kind: "init image", + expected: NETWORK_BLOCK_INIT_IMAGE.to_string(), + actual: records.len(), + }); + }; + if record.configuration.name != NETWORK_BLOCK_INIT_IMAGE + || record.configuration.descriptor.digest != NETWORK_BLOCK_INIT_IMAGE_DIGEST + { + return Err(CliError::InitImageMismatch { + image: NETWORK_BLOCK_INIT_IMAGE, + expected_digest: NETWORK_BLOCK_INIT_IMAGE_DIGEST, + }); + } + Ok(()) +} + +/// Build a foreground `container run` command with stable argument boundaries. +pub fn run_command(plan: &RunPlan, command_line: &str, cwd: &str, tty: bool) -> CliCommand { + let mut arguments = vec![ + CliArgument::literal("run"), + CliArgument::literal("--interactive"), + CliArgument::literal("--progress"), + CliArgument::literal("none"), + CliArgument::literal("--name"), + CliArgument::literal(plan.container.name.as_str()), + ]; + if tty { + arguments.push(CliArgument::literal("--tty")); + } + append_labels(&mut arguments, &plan.container, "--label"); + + match &plan.network { + NetworkPlan::DefaultNat => { + arguments.push(CliArgument::literal("--network")); + arguments.push(CliArgument::literal("default")); + } + NetworkPlan::Isolated { resource } => { + arguments.push(CliArgument::literal("--network")); + arguments.push(CliArgument::literal(resource.name.as_str())); + arguments.push(CliArgument::literal("--init-image")); + arguments.push(CliArgument::literal(NETWORK_BLOCK_INIT_IMAGE)); + } + } + + if let Some(environment_file) = &plan.environment_file { + arguments.push(CliArgument::literal("--env-file")); + arguments.push(CliArgument::sensitive(environment_file.path.as_os_str())); + } + if !cwd.is_empty() { + arguments.push(CliArgument::literal("--workdir")); + arguments.push(CliArgument::literal(cwd)); + } + if let Some(cpu_count) = plan.resources.cpu_count { + arguments.push(CliArgument::literal("--cpus")); + arguments.push(CliArgument::literal(cpu_count.to_string())); + } + if let Some(memory_mb) = plan.resources.memory_mb { + arguments.push(CliArgument::literal("--memory")); + arguments.push(CliArgument::literal(format!("{memory_mb}M"))); + } + for mount in &plan.mounts { + arguments.push(CliArgument::literal("--mount")); + let mut value = OsString::from("type=bind,source="); + value.push(&mount.host_path); + value.push(",target="); + value.push(&mount.guest_path); + if mount.access == MountAccess::ReadOnly { + value.push(",readonly"); + } + arguments.push(CliArgument::literal(value)); + } + + arguments.extend([ + CliArgument::literal(&plan.image), + CliArgument::literal("/bin/sh"), + CliArgument::literal("-lc"), + CliArgument::sensitive(command_line), + ]); + CliCommand::new(arguments) +} + +pub fn inspect_command(resource: &OwnedResource) -> CliCommand { + let arguments = match resource.name.kind() { + ResourceKind::Container => vec![ + CliArgument::literal("inspect"), + CliArgument::literal(resource.name.as_str()), + ], + ResourceKind::Network => vec![ + CliArgument::literal("network"), + CliArgument::literal("inspect"), + CliArgument::literal(resource.name.as_str()), + ], + }; + CliCommand::new(arguments) +} + +pub fn stop_container_command(resource: &OwnedResource) -> CliCommand { + debug_assert_eq!(resource.name.kind(), ResourceKind::Container); + CliCommand::new([ + CliArgument::literal("stop"), + CliArgument::literal("--signal"), + CliArgument::literal("KILL"), + CliArgument::literal("--time"), + CliArgument::literal("0"), + CliArgument::literal(resource.name.as_str()), + ]) +} + +pub fn delete_command(resource: &OwnedResource) -> CliCommand { + let arguments = match resource.name.kind() { + ResourceKind::Container => vec![ + CliArgument::literal("delete"), + CliArgument::literal(resource.name.as_str()), + ], + ResourceKind::Network => vec![ + CliArgument::literal("network"), + CliArgument::literal("delete"), + CliArgument::literal(resource.name.as_str()), + ], + }; + CliCommand::new(arguments) +} + +/// Read back and verify MXC ownership before any destructive command. +pub fn verify_ownership( + runner: &dyn CommandRunner, + resource: &OwnedResource, +) -> Result { + let command = inspect_command(resource); + let output = runner + .run(&command) + .map_err(|error| CliError::Command(error.to_string()))?; + if !output.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.to_ascii_lowercase().contains("not found") { + return Ok(false); + } + return Err(CliError::Exit { + command: command.diagnostic(), + status: output.exit_code, + stderr, + }); + } + let (id, labels) = + match resource.name.kind() { + ResourceKind::Container => { + let records: Vec = serde_json::from_slice(&output.stdout) + .map_err(|error| CliError::MalformedJson { + resource: "container inspect", + message: error.to_string(), + })?; + if records.len() != 1 { + return Err(CliError::UnexpectedInspectResult { + kind: "container", + expected: resource.name.as_str().to_string(), + actual: records.len(), + }); + } + let record = &records[0]; + if record.id != record.configuration.id { + return Err(CliError::MalformedJson { + resource: "container inspect", + message: "top-level and configuration IDs differ".to_string(), + }); + } + (record.id.clone(), record.configuration.labels.clone()) + } + ResourceKind::Network => { + let records: Vec = + serde_json::from_slice(&output.stdout).map_err(|error| { + CliError::MalformedJson { + resource: "network inspect", + message: error.to_string(), + } + })?; + if records.len() != 1 { + return Err(CliError::UnexpectedInspectResult { + kind: "network", + expected: resource.name.as_str().to_string(), + actual: records.len(), + }); + } + let record = &records[0]; + if record.id != record.configuration.name { + return Err(CliError::MalformedJson { + resource: "network inspect", + message: "top-level ID and configuration name differ".to_string(), + }); + } + (record.id.clone(), record.configuration.labels.clone()) + } + }; + if id != resource.name.as_str() || !resource.labels.matches(&labels) { + return Err(CliError::OwnershipMismatch { + kind: resource.name.kind().as_str(), + name: resource.name.as_str().to_string(), + }); + } + Ok(true) +} + +fn append_labels(arguments: &mut Vec, resource: &OwnedResource, flag: &str) { + for (key, value) in resource.labels.iter() { + arguments.push(CliArgument::literal(flag)); + arguments.push(CliArgument::literal(format!("{key}={value}"))); + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + + use super::*; + use crate::command::{CommandError, CommandRunner}; + use crate::plan::{EnvironmentFile, MountPlan, ResourceLimits}; + use crate::resource::OwnershipToken; + + fn token() -> OwnershipToken { + OwnershipToken::parse("0123456789abcdef0123456789abcdef").unwrap() + } + + fn isolated_plan() -> RunPlan { + RunPlan::new( + "docker.io/library/alpine:3.22@sha256:abc", + "build job", + &token(), + true, + vec![ + MountPlan::new("/host/rw", "/host/rw", MountAccess::ReadWrite).unwrap(), + MountPlan::new("/host/ro", "/host/ro", MountAccess::ReadOnly).unwrap(), + ], + Some(EnvironmentFile::new("/private/tmp/secret.env").unwrap()), + ResourceLimits::new(Some(2), Some(512)).unwrap(), + ) + .unwrap() + } + + fn arguments(command: &CliCommand) -> Vec { + command + .arguments() + .map(|value| value.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn run_argv_preserves_unsplit_workload_and_redacts_env_path() { + let command_line = "printf '%s\\n' \"$TOKEN\"; touch '/tmp/a b'"; + let command = run_command(&isolated_plan(), command_line, "/host/rw", false); + let argv = arguments(&command); + + assert_eq!( + &argv[argv.len() - 4..], + &[ + "docker.io/library/alpine:3.22@sha256:abc", + "/bin/sh", + "-lc", + command_line, + ] + ); + assert!(argv.contains(&NETWORK_BLOCK_INIT_IMAGE.to_string())); + assert!(argv.contains(&"type=bind,source=/host/ro,target=/host/ro,readonly".to_string())); + assert!(!command.diagnostic().contains("/private/tmp/secret.env")); + assert!(!command.diagnostic().contains(command_line)); + assert!(!argv.iter().any(|argument| argument == "--rm")); + } + + struct FakeRunner { + output: Mutex>>, + } + + impl CommandRunner for FakeRunner { + fn run(&self, _command: &CliCommand) -> Result { + self.output.lock().unwrap().pop_front().unwrap() + } + } + + #[test] + fn ownership_verification_rejects_foreign_labels() { + let plan = isolated_plan(); + let output = format!( + r#"[{{"id":"{0}","configuration":{{"id":"{0}","labels":{{"com.microsoft.mxc.managed":"true","com.microsoft.mxc.owner-token":"foreign","com.microsoft.mxc.resource-kind":"container"}}}}}}]"#, + plan.container.name.as_str() + ); + let runner = FakeRunner { + output: Mutex::new(VecDeque::from([Ok(CommandOutput { + exit_code: Some(0), + stdout: output.into_bytes(), + stderr: Vec::new(), + })])), + }; + + assert!(matches!( + verify_ownership(&runner, &plan.container), + Err(CliError::OwnershipMismatch { .. }) + )); + } +} diff --git a/src/backends/apple_container/common/src/command.rs b/src/backends/apple_container/common/src/command.rs index 8bb6c5784..46ab637bf 100644 --- a/src/backends/apple_container/common/src/command.rs +++ b/src/backends/apple_container/common/src/command.rs @@ -2,7 +2,10 @@ // Licensed under the MIT License. use std::ffi::{OsStr, OsString}; -use std::time::Duration; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; use thiserror::Error; @@ -164,6 +167,227 @@ pub trait CommandRunner: Send + Sync { fn run(&self, command: &CliCommand) -> Result; } +/// Production runner for bounded Apple Container management commands. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemCommandRunner; + +impl CommandRunner for SystemCommandRunner { + fn run(&self, command: &CliCommand) -> Result { + run_program(command.program(), command) + } +} + +fn run_program(program: &OsStr, command: &CliCommand) -> Result { + let diagnostic = command.diagnostic(); + let mut child = Command::new(program) + .args(command.arguments()) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + CommandError::new( + CommandErrorKind::Spawn, + format!("failed to start {diagnostic}: {error}"), + ) + })?; + + let Some(stdout) = child.stdout.take() else { + terminate_and_reap(&mut child); + return Err(CommandError::new( + CommandErrorKind::Spawn, + format!("{diagnostic} did not provide a stdout pipe"), + )); + }; + let Some(stderr) = child.stderr.take() else { + terminate_and_reap(&mut child); + return Err(CommandError::new( + CommandErrorKind::Spawn, + format!("{diagnostic} did not provide a stderr pipe"), + )); + }; + + let (sender, receiver) = mpsc::channel(); + read_stream(stdout, StreamKind::Stdout, sender.clone()); + read_stream(stderr, StreamKind::Stderr, sender); + + let deadline = Instant::now() + command.timeout(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut readers_open = 2; + let exit_code = loop { + drain_available( + &receiver, + &mut stdout, + &mut stderr, + &mut readers_open, + command.output_limit(), + &diagnostic, + &mut child, + )?; + + match child.try_wait() { + Ok(Some(status)) => break status.code(), + Ok(None) => {} + Err(error) => { + terminate_and_reap(&mut child); + return Err(CommandError::new( + CommandErrorKind::Wait, + format!("failed while waiting for {diagnostic}: {error}"), + )); + } + } + + if Instant::now() >= deadline { + terminate_and_reap(&mut child); + return Err(CommandError::new( + CommandErrorKind::Timeout, + format!("{diagnostic} exceeded its {:?} timeout", command.timeout()), + )); + } + std::thread::sleep(Duration::from_millis(5)); + }; + + let drain_deadline = Instant::now() + Duration::from_secs(1); + while readers_open > 0 { + if Instant::now() >= drain_deadline { + return Err(CommandError::new( + CommandErrorKind::Wait, + format!("{diagnostic} output pipes did not close after process exit"), + )); + } + match receiver.recv_timeout(Duration::from_millis(10)) { + Ok(message) => append_message( + message, + &mut stdout, + &mut stderr, + &mut readers_open, + command.output_limit(), + &diagnostic, + )?, + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => readers_open = 0, + } + } + + Ok(CommandOutput { + exit_code, + stdout, + stderr, + }) +} + +#[derive(Debug, Clone, Copy)] +enum StreamKind { + Stdout, + Stderr, +} + +enum StreamMessage { + Data(StreamKind, Vec), + Closed, + Failed(StreamKind, std::io::Error), +} + +fn read_stream( + mut stream: impl Read + Send + 'static, + kind: StreamKind, + sender: mpsc::Sender, +) { + std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => { + let _ = sender.send(StreamMessage::Closed); + return; + } + Ok(count) => { + if sender + .send(StreamMessage::Data(kind, buffer[..count].to_vec())) + .is_err() + { + return; + } + } + Err(error) => { + let _ = sender.send(StreamMessage::Failed(kind, error)); + return; + } + } + } + }); +} + +#[allow(clippy::too_many_arguments)] +fn drain_available( + receiver: &mpsc::Receiver, + stdout: &mut Vec, + stderr: &mut Vec, + readers_open: &mut usize, + output_limit: usize, + diagnostic: &str, + child: &mut std::process::Child, +) -> Result<(), CommandError> { + while let Ok(message) = receiver.try_recv() { + if let Err(error) = append_message( + message, + stdout, + stderr, + readers_open, + output_limit, + diagnostic, + ) { + terminate_and_reap(child); + return Err(error); + } + } + Ok(()) +} + +fn append_message( + message: StreamMessage, + stdout: &mut Vec, + stderr: &mut Vec, + readers_open: &mut usize, + output_limit: usize, + diagnostic: &str, +) -> Result<(), CommandError> { + match message { + StreamMessage::Data(kind, bytes) => { + if stdout + .len() + .saturating_add(stderr.len()) + .saturating_add(bytes.len()) + > output_limit + { + return Err(CommandError::new( + CommandErrorKind::OutputLimit, + format!("{diagnostic} output exceeded the {output_limit}-byte limit"), + )); + } + match kind { + StreamKind::Stdout => stdout.extend_from_slice(&bytes), + StreamKind::Stderr => stderr.extend_from_slice(&bytes), + } + } + StreamMessage::Closed => *readers_open = readers_open.saturating_sub(1), + StreamMessage::Failed(kind, error) => { + return Err(CommandError::new( + CommandErrorKind::Wait, + format!("{diagnostic} failed reading {kind:?}: {error}"), + )); + } + } + Ok(()) +} + +fn terminate_and_reap(child: &mut std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + #[cfg(test)] mod tests { use super::*; @@ -191,4 +415,21 @@ mod tests { assert_eq!(command.timeout(), DEFAULT_COMMAND_TIMEOUT); assert_eq!(command.output_limit(), DEFAULT_COMMAND_OUTPUT_LIMIT); } + + #[cfg(unix)] + #[test] + fn system_runner_enforces_timeout() { + let command = + CliCommand::new([CliArgument::literal("5")]).with_timeout(Duration::from_millis(25)); + let error = run_program(OsStr::new("/bin/sleep"), &command).unwrap_err(); + assert_eq!(error.kind, CommandErrorKind::Timeout); + } + + #[cfg(unix)] + #[test] + fn system_runner_enforces_combined_output_limit() { + let command = CliCommand::new([CliArgument::literal("x")]).with_output_limit(128); + let error = run_program(OsStr::new("/usr/bin/yes"), &command).unwrap_err(); + assert_eq!(error.kind, CommandErrorKind::OutputLimit); + } } diff --git a/src/backends/apple_container/common/src/lib.rs b/src/backends/apple_container/common/src/lib.rs index 9e85ce862..428efb41f 100644 --- a/src/backends/apple_container/common/src/lib.rs +++ b/src/backends/apple_container/common/src/lib.rs @@ -9,23 +9,32 @@ //! runner in a later increment. pub mod availability; +pub mod cli; pub mod command; pub mod plan; +pub mod policy; +#[cfg(target_os = "macos")] +pub mod recovery; pub mod resource; +#[cfg(target_os = "macos")] +pub mod runtime; pub use availability::{ - check_cli_installed, probe_with, AppleContainerAvailability, AppleContainerVersion, HostInfo, - SystemStatus, APPLE_CONTAINER_CLI_PATH, APPLE_CONTAINER_RELEASES_URL, - QUALIFIED_APPLE_CONTAINER_VERSION, + check_cli_installed, is_available, probe, probe_with, AppleContainerAvailability, + AppleContainerVersion, HostInfo, SystemStatus, APPLE_CONTAINER_CLI_PATH, + APPLE_CONTAINER_RELEASES_URL, QUALIFIED_APPLE_CONTAINER_VERSION, }; pub use command::{ CliArgument, CliCommand, CommandError, CommandErrorKind, CommandOutput, CommandRunner, - DEFAULT_COMMAND_OUTPUT_LIMIT, DEFAULT_COMMAND_TIMEOUT, + SystemCommandRunner, DEFAULT_COMMAND_OUTPUT_LIMIT, DEFAULT_COMMAND_TIMEOUT, }; pub use plan::{ CleanupPlan, EnvironmentFile, MountAccess, MountPlan, NetworkPlan, ResourceLimits, RunPlan, }; +pub use policy::{build_run_plan, validate_policy, PolicyError}; pub use resource::{ ContainerName, NetworkName, OwnedResource, OwnershipLabels, OwnershipToken, ResourceKind, ResourceName, ResourceNames, }; +#[cfg(target_os = "macos")] +pub use runtime::AppleContainerBackend; diff --git a/src/backends/apple_container/common/src/plan.rs b/src/backends/apple_container/common/src/plan.rs index a0496df6b..92758672a 100644 --- a/src/backends/apple_container/common/src/plan.rs +++ b/src/backends/apple_container/common/src/plan.rs @@ -13,6 +13,8 @@ pub enum PlanError { RelativePath { field: &'static str }, #[error("Apple Container image must not be empty")] EmptyImage, + #[error("Apple Container image must not begin with '-'")] + InvalidImage, #[error("Apple Container CPU count must be greater than zero")] InvalidCpuCount, #[error("Apple Container memory limit must be greater than zero")] @@ -101,6 +103,7 @@ impl ResourceLimits { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RunPlan { pub image: String, + pub ownership_token: OwnershipToken, pub container: OwnedResource, pub network: NetworkPlan, pub mounts: Vec, @@ -120,9 +123,14 @@ impl RunPlan { resources: ResourceLimits, ) -> Result { let image = image.into(); - if image.trim().is_empty() { + let image = image.trim(); + if image.is_empty() { return Err(PlanError::EmptyImage); } + if image.starts_with('-') { + return Err(PlanError::InvalidImage); + } + let image = image.to_string(); let names = ResourceNames::new(container_hint, token); let container = OwnedResource::container(names.container, token); let network = if isolated_network { @@ -134,6 +142,7 @@ impl RunPlan { }; Ok(Self { image, + ownership_token: token.clone(), container, network, mounts, @@ -175,7 +184,6 @@ mod tests { #[cfg(target_os = "macos")] use crate::resource::ResourceKind; - #[cfg(target_os = "macos")] fn token() -> OwnershipToken { OwnershipToken::parse("0123456789abcdef0123456789abcdef").unwrap() } @@ -212,4 +220,20 @@ mod tests { assert!(ResourceLimits::new(Some(0), None).is_err()); assert!(ResourceLimits::new(None, Some(0)).is_err()); } + + #[test] + fn plans_reject_image_values_that_look_like_cli_options() { + assert!(matches!( + RunPlan::new( + "--rm", + "test", + &token(), + false, + Vec::new(), + None, + ResourceLimits::default(), + ), + Err(PlanError::InvalidImage) + )); + } } diff --git a/src/backends/apple_container/common/src/policy.rs b/src/backends/apple_container/common/src/policy.rs new file mode 100644 index 000000000..c0d69cc2b --- /dev/null +++ b/src/backends/apple_container/common/src/policy.rs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +use thiserror::Error; +use wxc_common::models::{ + ClipboardPolicy, ExecutionRequest, NetworkEnforcementMode, NetworkPolicy, +}; + +use crate::plan::{EnvironmentFile, MountAccess, MountPlan, PlanError, ResourceLimits, RunPlan}; +use crate::resource::{OwnershipToken, ResourceError}; + +#[derive(Debug, Error)] +pub enum PolicyError { + #[error("{0}")] + Rejected(String), + #[error("{0}")] + Plan(#[from] PlanError), + #[error("{0}")] + Resource(#[from] ResourceError), + #[error("failed to canonicalize Apple Container mount path {path:?}: {source}")] + Canonicalize { + path: String, + #[source] + source: std::io::Error, + }, +} + +/// Validate the Apple Container policy and create an immutable launch plan. +pub fn build_run_plan( + request: &ExecutionRequest, + environment_file: Option, +) -> Result { + validate_policy(request)?; + let config = request + .experimental + .apple_container + .as_ref() + .ok_or_else(|| { + PolicyError::Rejected( + "Apple Container requires experimental.apple_container configuration".to_string(), + ) + })?; + let mounts = build_mounts(request)?; + let token = OwnershipToken::generate()?; + let resources = ResourceLimits::new(config.cpu_count, config.memory_mb)?; + RunPlan::new( + &config.image, + &request.container_id, + &token, + request.policy.default_network_policy == NetworkPolicy::Block, + mounts, + environment_file, + resources, + ) + .map_err(Into::into) +} + +/// Validate the complete one-shot policy before any Apple resource is created. +pub fn validate_policy(request: &ExecutionRequest) -> Result<(), PolicyError> { + if !request.experimental_enabled { + return Err(rejected( + "Apple Container is experimental; use the --experimental flag", + )); + } + if request.experimental.apple_container.is_none() { + return Err(rejected( + "Apple Container requires experimental.apple_container configuration", + )); + } + if !request.policy.capabilities.is_empty() { + return Err(rejected( + "Apple Container does not support requested process capabilities", + )); + } + if request.policy.least_privilege_mode { + return Err(rejected( + "Apple Container does not support process leastPrivilegeMode", + )); + } + if request.policy.fallback_specified { + return Err(rejected( + "Apple Container does not support the Windows-only fallback policy", + )); + } + if request.policy.allow_local_network { + return Err(rejected( + "Apple Container does not support network.allowLocalNetwork=true", + )); + } + if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { + return Err(rejected( + "Apple Container does not support network.allowedHosts or network.blockedHosts", + )); + } + if request.policy.network_proxy.is_enabled() { + return Err(rejected("Apple Container does not support network.proxy")); + } + if request.policy.network_enforcement_mode_specified + || request.policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities + { + return Err(rejected( + "Apple Container does not support network.enforcementMode", + )); + } + if !request.policy.ui.disable + || request.policy.ui.clipboard != ClipboardPolicy::None + || request.policy.ui.injection + { + return Err(rejected( + "Apple Container supports only the default-deny UI policy", + )); + } + if !request.lifecycle.destroy_on_exit { + return Err(rejected( + "Apple Container one-shot execution requires lifecycle.destroyOnExit=true", + )); + } + if request.lifecycle.preserve_policy { + return Err(rejected( + "Apple Container one-shot execution does not support lifecycle.preservePolicy=true", + )); + } + if request.policy.capture_denials.is_some() { + return Err(rejected( + "Apple Container does not support processContainer.captureDenials", + )); + } + let process_container_ui = &request.policy.base_process_ui; + if process_container_ui.isolation != "container" + || process_container_ui.desktop_system_control + || process_container_ui.system_settings != "none" + || process_container_ui.ime + { + return Err(rejected( + "Apple Container does not support processContainer.ui", + )); + } + if !request.working_directory.is_empty() && !Path::new(&request.working_directory).is_absolute() + { + return Err(rejected( + "Apple Container process.cwd must be an absolute guest path", + )); + } + validate_environment(&request.env)?; + Ok(()) +} + +fn validate_environment(environment: &[String]) -> Result<(), PolicyError> { + for entry in environment { + let Some((key, _)) = entry.split_once('=') else { + return Err(rejected( + "Apple Container process.env entries must use KEY=VALUE form", + )); + }; + if key.is_empty() + || !key + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || key.contains('\0') + || key.contains('\n') + || key.contains('\r') + || key.contains('=') + { + return Err(rejected( + "Apple Container process.env contains an invalid variable name", + )); + } + if entry.contains('\0') || entry.contains('\n') || entry.contains('\r') { + return Err(rejected( + "Apple Container process.env values must not contain NUL or newline characters", + )); + } + } + Ok(()) +} + +fn build_mounts(request: &ExecutionRequest) -> Result, PolicyError> { + let mut destinations = BTreeMap::::new(); + let mut mounts = Vec::new(); + for (paths, access) in [ + (&request.policy.readwrite_paths, MountAccess::ReadWrite), + (&request.policy.readonly_paths, MountAccess::ReadOnly), + ] { + for requested in paths { + let requested_path = Path::new(requested); + if !requested_path.is_absolute() { + return Err(rejected(format!( + "Apple Container mount path {requested:?} must be absolute" + ))); + } + let canonical = std::fs::canonicalize(requested_path).map_err(|source| { + PolicyError::Canonicalize { + path: requested.clone(), + source, + } + })?; + let canonical_text = canonical.to_str().ok_or_else(|| { + rejected(format!( + "Apple Container mount path {requested:?} resolves to a non-UTF-8 path" + )) + })?; + if canonical_text.contains(',') { + return Err(rejected(format!( + "Apple Container mount path {canonical_text:?} contains ',' and cannot be represented safely" + ))); + } + if let Some(existing) = destinations.insert(canonical.clone(), access) { + let conflict = if existing == access { + "duplicate" + } else { + "conflicting read-only/read-write" + }; + return Err(rejected(format!( + "Apple Container has {conflict} mount destination {canonical:?}" + ))); + } + mounts.push(MountPlan::new(&canonical, &canonical, access)?); + } + } + + for denied in &request.policy.denied_paths { + let denied = normalize_absolute(Path::new(denied)).ok_or_else(|| { + rejected(format!( + "Apple Container denied path {denied:?} must be an absolute normalized path" + )) + })?; + if destinations + .keys() + .any(|mapped| denied == *mapped || denied.starts_with(mapped)) + { + return Err(rejected(format!( + "Apple Container cannot deny {denied:?} because it is within a mapped path" + ))); + } + } + Ok(mounts) +} + +fn normalize_absolute(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir => normalized.push(component), + Component::Normal(value) => normalized.push(value), + Component::CurDir => {} + Component::ParentDir | Component::Prefix(_) => return None, + } + } + Some(normalized) +} + +fn rejected(message: impl Into) -> PolicyError { + PolicyError::Rejected(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wxc_common::models::{AppleContainerConfig, ExperimentalConfig}; + + fn request() -> ExecutionRequest { + ExecutionRequest { + container_id: "test".to_string(), + script_code: "echo ok".to_string(), + containment: wxc_common::models::ContainmentBackend::AppleContainer, + experimental_enabled: true, + experimental: ExperimentalConfig { + apple_container: Some(AppleContainerConfig { + image: "alpine:3.22".to_string(), + cpu_count: None, + memory_mb: None, + }), + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn rejects_every_unsupported_network_and_lifecycle_grant() { + let mut cases = Vec::new(); + let mut local = request(); + local.policy.allow_local_network = true; + cases.push(local); + let mut hosts = request(); + hosts.policy.allowed_hosts.push("example.com".to_string()); + cases.push(hosts); + let mut enforcement = request(); + enforcement.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; + cases.push(enforcement); + let mut retain = request(); + retain.lifecycle.destroy_on_exit = false; + cases.push(retain); + let mut preserve = request(); + preserve.lifecycle.preserve_policy = true; + cases.push(preserve); + let mut process_container_ui = request(); + process_container_ui.policy.base_process_ui.ime = true; + cases.push(process_container_ui); + + for case in cases { + assert!(validate_policy(&case).is_err()); + } + } + + #[test] + fn rejects_unsafe_environment_file_entries() { + for entry in ["PATH", "=value", "KEY=line\nnext", "BAD\rKEY=value"] { + let mut request = request(); + request.env = vec![entry.to_string()]; + assert!(validate_policy(&request).is_err(), "accepted {entry:?}"); + } + } + + #[test] + fn accepts_default_deny_policy() { + assert!(validate_policy(&request()).is_ok()); + } +} diff --git a/src/backends/apple_container/common/src/recovery.rs b/src/backends/apple_container/common/src/recovery.rs new file mode 100644 index 000000000..9be4c68f2 --- /dev/null +++ b/src/backends/apple_container/common/src/recovery.rs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::plan::{CleanupPlan, RunPlan}; +use crate::resource::{OwnedResource, OwnershipToken, ResourceNames}; + +const RECORD_VERSION: u32 = 1; +const MAX_RECOVERY_RECORDS: usize = 32; +const MAX_RECORD_BYTES: u64 = 64 * 1024; + +#[derive(Debug, Error)] +pub enum RecoveryError { + #[error("Apple Container recovery record error: {0}")] + Io(#[from] std::io::Error), + #[error("Apple Container recovery record is malformed: {0}")] + Malformed(#[from] serde_json::Error), + #[error("unsupported Apple Container recovery record version {0}")] + UnsupportedVersion(u32), + #[error("too many Apple Container recovery records (maximum {MAX_RECOVERY_RECORDS})")] + TooManyRecords, + #[error("Apple Container recovery record exceeds {MAX_RECORD_BYTES} bytes")] + RecordTooLarge, + #[error("Apple Container recovery record name does not match its ownership token")] + NameMismatch, + #[error("{0}")] + InvalidToken(#[from] crate::resource::ResourceError), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RecoveryRecord { + version: u32, + owner_pid: u32, + container_hint: String, + ownership_token: String, +} + +impl RecoveryRecord { + fn from_plan(plan: &RunPlan, container_hint: &str) -> Self { + Self { + version: RECORD_VERSION, + owner_pid: std::process::id(), + container_hint: container_hint.to_string(), + ownership_token: plan.ownership_token.as_str().to_string(), + } + } + + fn cleanup_plan(&self) -> Result { + if self.version != RECORD_VERSION { + return Err(RecoveryError::UnsupportedVersion(self.version)); + } + let token = OwnershipToken::parse(&self.ownership_token)?; + let names = ResourceNames::new(&self.container_hint, &token); + Ok(CleanupPlan { + container: OwnedResource::container(names.container, &token), + network: Some(OwnedResource::network(names.network, &token)), + }) + } +} + +/// Locked record retained for the lifetime of one Apple Container execution. +pub struct RecoveryGuard { + path: PathBuf, + _file: File, +} + +impl RecoveryGuard { + pub fn create(plan: &RunPlan, container_hint: &str) -> Result { + let directory = recovery_directory()?; + create_private_directory(&directory)?; + let path = directory.join(format!("{}.json", plan.ownership_token.as_str())); + let temporary_path = directory.join(format!(".{}.tmp", plan.ownership_token.as_str())); + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary_path)?; + lock_exclusive(&file, false)?; + let write_result = (|| { + serde_json::to_writer(&mut file, &RecoveryRecord::from_plan(plan, container_hint))?; + file.write_all(b"\n")?; + file.sync_all()?; + fs::hard_link(&temporary_path, &path)?; + fs::remove_file(&temporary_path)?; + File::open(&directory)?.sync_all()?; + Ok::<(), RecoveryError>(()) + })(); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary_path); + return Err(error); + } + Ok(Self { path, _file: file }) + } + + pub fn complete(self) -> Result<(), RecoveryError> { + remove_completed_record(&self.path) + } +} + +/// One unlocked record whose creating executor has exited. +pub struct StaleRecovery { + path: PathBuf, + _file: File, + cleanup: CleanupPlan, +} + +impl StaleRecovery { + pub fn cleanup_plan(&self) -> &CleanupPlan { + &self.cleanup + } + + pub fn complete(self) -> Result<(), RecoveryError> { + remove_completed_record(&self.path) + } +} + +/// Find records whose kernel-held owner lock has been released. +pub fn stale_recoveries() -> Result, RecoveryError> { + let directory = recovery_directory()?; + create_private_directory(&directory)?; + let mut records = Vec::new(); + let mut record_count = 0; + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + let extension = path.extension().and_then(|value| value.to_str()); + if !matches!(extension, Some("json" | "tmp")) { + continue; + } + record_count += 1; + if record_count > MAX_RECOVERY_RECORDS { + return Err(RecoveryError::TooManyRecords); + } + if !fs::symlink_metadata(&path)?.file_type().is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("recovery record {path:?} is not a regular file"), + ) + .into()); + } + let mut file = match fs::OpenOptions::new().read(true).write(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + if !lock_exclusive(&file, true)? { + continue; + } + if extension == Some("tmp") { + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + continue; + } + if file.metadata()?.len() > MAX_RECORD_BYTES { + return Err(RecoveryError::RecordTooLarge); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + let record: RecoveryRecord = serde_json::from_slice(&bytes)?; + let cleanup = record.cleanup_plan()?; + let expected_name = format!("{}.json", record.ownership_token); + if path.file_name().and_then(|value| value.to_str()) != Some(&expected_name) { + return Err(RecoveryError::NameMismatch); + } + records.push(StaleRecovery { + path, + _file: file, + cleanup, + }); + } + Ok(records) +} + +fn remove_completed_record(path: &Path) -> Result<(), RecoveryError> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn recovery_directory() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "HOME is unavailable for Apple Container recovery records", + ) + })?; + let home = Path::new(&home); + if !home.is_absolute() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "HOME must be absolute for Apple Container recovery records", + ) + .into()); + } + Ok(home.join("Library/Application Support/Microsoft/MXC/apple-container/recovery")) +} + +fn create_private_directory(path: &Path) -> Result<(), std::io::Error> { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + if !fs::symlink_metadata(path)?.file_type().is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("recovery path {path:?} is not a directory"), + )); + } + Ok(()) +} + +fn lock_exclusive(file: &File, nonblocking: bool) -> Result { + let operation = libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 }; + // SAFETY: `file` owns a valid descriptor and `flock` does not retain it. + if unsafe { libc::flock(file.as_raw_fd(), operation) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if nonblocking && matches!(error.raw_os_error(), Some(libc::EWOULDBLOCK)) { + Ok(false) + } else { + Err(error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_reconstructs_exact_owned_resource_names() { + let token = OwnershipToken::parse("0123456789abcdef0123456789abcdef").unwrap(); + let plan = RunPlan::new( + "alpine:3.22", + "Build Job", + &token, + true, + Vec::new(), + None, + Default::default(), + ) + .unwrap(); + let record = RecoveryRecord::from_plan(&plan, "Build Job"); + let cleanup = record.cleanup_plan().unwrap(); + assert_eq!( + cleanup.container.name.as_str(), + plan.container.name.as_str() + ); + assert_eq!( + cleanup.network.as_ref().map(|value| value.name.as_str()), + plan.cleanup_plan() + .network + .as_ref() + .map(|value| value.name.as_str()) + ); + } +} diff --git a/src/backends/apple_container/common/src/runtime.rs b/src/backends/apple_container/common/src/runtime.rs new file mode 100644 index 000000000..b724eb24a --- /dev/null +++ b/src/backends/apple_container/common/src/runtime.rs @@ -0,0 +1,579 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use wxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller}; +use wxc_common::logger::Logger; +use wxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; +use wxc_common::sandbox_process::{ + boxed_closer, cancel_and_join_discard, group_kill, spawn_discard, take_boxed_read, + take_boxed_write, wait_with_timeout, SandboxBackend, SandboxProcess, StdioMode, StreamCloser, + WaitError, +}; +use wxc_common::validator::validate_common; + +use crate::availability::probe; +use crate::cli::{ + create_network_command, delete_command, run_checked, run_command, stop_container_command, + verify_network_block_init_image, verify_ownership, CliError, +}; +use crate::command::{CommandRunner, SystemCommandRunner, DEFAULT_COMMAND_TIMEOUT}; +use crate::plan::{CleanupPlan, EnvironmentFile, NetworkPlan, RunPlan}; +use crate::policy::{build_run_plan, validate_policy}; +use crate::recovery::{stale_recoveries, RecoveryGuard}; +use crate::resource::{OwnedResource, ResourceKind}; + +#[derive(Default)] +pub struct AppleContainerBackend; + +impl AppleContainerBackend { + pub fn new() -> Self { + Self + } +} + +impl SandboxBackend for AppleContainerBackend { + fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + validate_policy(request).map_err(|error| rejected(error.to_string())) + } + + fn spawn( + &mut self, + request: &ExecutionRequest, + _logger: &mut Logger, + stdio: StdioMode, + ) -> Result, ScriptResponse> { + validate_common(request)?; + self.validate(request)?; + probe().map_err(|error| backend_unavailable(error.to_string()))?; + + let runner: Arc = Arc::new(SystemCommandRunner); + recover_stale_resources(runner.as_ref()) + .map_err(|error| backend_unavailable(error.to_string()))?; + + let environment = SecureEnvironmentFile::create(&request.env).map_err(|error| { + launch_failed(format!("failed to create environment file: {error}")) + })?; + let environment_plan = environment + .as_ref() + .map(|file| EnvironmentFile::new(file.path.clone())) + .transpose() + .map_err(|error| rejected(error.to_string()))?; + let plan = build_run_plan(request, environment_plan) + .map_err(|error| rejected(error.to_string()))?; + if matches!(plan.network, NetworkPlan::Isolated { .. }) { + verify_network_block_init_image(runner.as_ref()) + .map_err(|error| backend_unavailable(error.to_string()))?; + } + + let recovery = RecoveryGuard::create(&plan, &request.container_id) + .map_err(|error| launch_failed(error.to_string()))?; + + if let NetworkPlan::Isolated { resource } = &plan.network { + if let Err(error) = run_checked(runner.as_ref(), &create_network_command(resource)) { + let cleanup = + finish_cleanup(runner.as_ref(), &plan.cleanup_plan(), false, recovery); + return Err(match cleanup { + Ok(()) => launch_failed(error.to_string()), + Err(cleanup_error) => { + launch_failed(format!("{error}; cleanup also failed: {cleanup_error}")) + } + }); + } + } + + match spawn_foreground(request, plan, environment, recovery, runner, stdio) { + Ok(process) => Ok(Box::new(process)), + Err((error, cleanup)) => { + if let Err(cleanup_error) = cleanup { + Err(launch_failed(format!( + "{error}; cleanup also failed: {cleanup_error}" + ))) + } else { + Err(launch_failed(error)) + } + } + } + } +} + +fn spawn_foreground( + request: &ExecutionRequest, + plan: RunPlan, + environment: Option, + recovery: RecoveryGuard, + runner: Arc, + stdio: StdioMode, +) -> Result)> { + let cwd = if request.working_directory.is_empty() { + "/" + } else { + &request.working_directory + }; + let tty = stdio == StdioMode::Inherit && stdio_is_tty(); + let cli_command = run_command(&plan, &request.script_code, cwd, tty); + let mut command = Command::new(cli_command.program()); + command.args(cli_command.arguments()).env_clear(); + let grouped = + stdio == StdioMode::Pipes || (stdio == StdioMode::Inherit && request.script_timeout != 0); + if grouped { + command.process_group(0); + } + match stdio { + StdioMode::Pipes => { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + StdioMode::Inherit => { + command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + } + } + + let cleanup_plan = plan.cleanup_plan(); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let cleanup = finish_cleanup(runner.as_ref(), &cleanup_plan, false, recovery); + return Err(( + format!("failed to start {}: {error}", cli_command.diagnostic()), + cleanup, + )); + } + }; + let stdin = child.stdin.take(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let (stdout, stdout_canceller, stderr, stderr_canceller) = + match wrap_child_pipes(stdout, stderr) { + Ok(pipes) => pipes, + Err(error) => { + if group_kill(&mut child).is_err() { + let _ = child.kill(); + } + let _ = wait_with_timeout(&mut child, Some(DEFAULT_COMMAND_TIMEOUT)); + let cleanup = finish_cleanup(runner.as_ref(), &cleanup_plan, true, recovery); + return Err(( + format!("failed to wrap Apple Container stdio: {error}"), + cleanup, + )); + } + }; + + Ok(AppleContainerProcess { + child, + stdin, + stdout, + stderr, + stdout_canceller, + stderr_canceller, + timeout: (request.script_timeout != 0) + .then(|| Duration::from_millis(u64::from(request.script_timeout))), + grouped, + cleanup: Some(cleanup_plan), + environment, + recovery: Some(recovery), + runner, + }) +} + +type WrappedPipes = ( + Option, + Option, + Option, + Option, +); + +fn wrap_child_pipes( + stdout: Option, + stderr: Option, +) -> std::io::Result { + let (stdout, stdout_canceller) = wrap_pipe(stdout)?; + let (stderr, stderr_canceller) = wrap_pipe(stderr)?; + Ok((stdout, stdout_canceller, stderr, stderr_canceller)) +} + +struct AppleContainerProcess { + child: Child, + stdin: Option, + stdout: Option, + stderr: Option, + stdout_canceller: Option, + stderr_canceller: Option, + timeout: Option, + grouped: bool, + cleanup: Option, + environment: Option, + recovery: Option, + runner: Arc, +} + +impl AppleContainerProcess { + fn terminate_owned(&mut self) -> std::io::Result<()> { + let child_result = match self.child.try_wait() { + Ok(Some(_)) => Ok(()), + Ok(None) => self.kill_local_child(), + Err(error) => { + let kill_result = self.kill_local_child(); + match kill_result { + Ok(()) => Err(error), + Err(kill_error) => Err(std::io::Error::new( + error.kind(), + format!( + "failed to inspect Apple Container CLI child: {error}; \ + additionally failed to terminate it: {kill_error}" + ), + )), + } + } + }; + + let stop_result = match &self.cleanup { + Some(cleanup) => match verify_ownership(self.runner.as_ref(), &cleanup.container) { + Ok(true) => run_checked( + self.runner.as_ref(), + &stop_container_command(&cleanup.container), + ) + .map(|_| ()) + .map_err(cli_io_error), + Ok(false) => Ok(()), + Err(error) => Err(cli_io_error(error)), + }, + None => Ok(()), + }; + + stop_result.and(child_result) + } + + fn kill_local_child(&mut self) -> std::io::Result<()> { + if self.grouped { + if let Err(group_error) = group_kill(&mut self.child) { + return self.child.kill().map_err(|child_error| { + std::io::Error::new( + group_error.kind(), + format!( + "failed to terminate Apple Container CLI process group: \ + {group_error}; child termination also failed: {child_error}" + ), + ) + }); + } + } else { + self.child.kill()?; + } + Ok(()) + } + + fn reap_local_child_bounded(&mut self) { + let _ = wait_with_timeout(&mut self.child, Some(DEFAULT_COMMAND_TIMEOUT)); + } + + fn cleanup(&mut self, stop_container: bool) -> std::io::Result<()> { + let Some(plan) = self.cleanup.take() else { + return Ok(()); + }; + let result = + cleanup_resources(self.runner.as_ref(), &plan, stop_container).map_err(cli_io_error); + let result = match (result, self.recovery.take()) { + (Ok(()), Some(recovery)) => recovery + .complete() + .map_err(|error| std::io::Error::other(error.to_string())), + (result, _) => result, + }; + self.environment.take(); + result + } +} + +impl SandboxProcess for AppleContainerProcess { + fn take_stdin(&mut self) -> Option> { + take_boxed_write(&mut self.stdin) + } + + fn take_stdout(&mut self) -> Option> { + take_boxed_read(&mut self.stdout) + } + + fn take_stderr(&mut self) -> Option> { + take_boxed_read(&mut self.stderr) + } + + fn stdout_closer(&self) -> Option> { + boxed_closer(&self.stdout_canceller) + } + + fn stderr_closer(&self) -> Option> { + boxed_closer(&self.stderr_canceller) + } + + fn try_wait(&mut self) -> std::io::Result> { + Ok(self + .child + .try_wait()? + .map(|status| status.code().unwrap_or(-1))) + } + + fn id(&self) -> u32 { + self.child.id() + } + + fn kill(&mut self) -> std::io::Result<()> { + self.terminate_owned() + } + + fn wait(&mut self) -> std::io::Result { + self.stdin.take(); + let stdout_thread = spawn_discard(self.stdout.take()); + let stderr_thread = spawn_discard(self.stderr.take()); + + let result = match wait_with_timeout(&mut self.child, self.timeout) { + Ok(status) => { + let exit_code = status.code().unwrap_or(-1); + let ownership = self + .cleanup + .as_ref() + .map(|plan| verify_ownership(self.runner.as_ref(), &plan.container)) + .transpose() + .map(|value| value.unwrap_or(false)) + .map_err(cli_io_error); + let cleanup = self.cleanup(false); + match (ownership, cleanup) { + (Ok(false), Ok(())) => Err(std::io::Error::other(format!( + "Apple Container CLI exited with status {exit_code} before creating the owned container" + ))), + (Ok(true), Ok(())) => Ok(exit_code), + (Err(error), _) => Err(error), + (_, Err(error)) => Err(error), + } + } + Err(WaitError::Timeout) => { + let termination = self.terminate_owned(); + self.reap_local_child_bounded(); + let cleanup = self.cleanup(false); + let message = termination.and(cleanup).err().map_or_else( + || "Apple Container workload timed out".to_string(), + |error| { + format!("Apple Container workload timed out and cleanup failed: {error}") + }, + ); + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, message)) + } + Err(WaitError::Io(error)) => { + let termination = self.terminate_owned(); + self.reap_local_child_bounded(); + let cleanup = self.cleanup(false); + let kind = error.kind(); + let message = termination.and(cleanup).err().map_or_else( + || format!("wait failed: {error}"), + |cleanup_error| { + format!("wait failed: {error}; cleanup failed: {cleanup_error}") + }, + ); + Err(std::io::Error::new(kind, message)) + } + }; + + cancel_and_join_discard(stdout_thread, &self.stdout_canceller); + cancel_and_join_discard(stderr_thread, &self.stderr_canceller); + result + } +} + +impl Drop for AppleContainerProcess { + fn drop(&mut self) { + let _ = self.terminate_owned(); + self.reap_local_child_bounded(); + let _ = self.cleanup(false); + } +} + +fn cleanup_resources( + runner: &dyn CommandRunner, + plan: &CleanupPlan, + stop_container: bool, +) -> Result<(), CliError> { + cleanup_resource(runner, &plan.container, stop_container)?; + if let Some(network) = &plan.network { + cleanup_resource(runner, network, false)?; + } + Ok(()) +} + +fn finish_cleanup( + runner: &dyn CommandRunner, + plan: &CleanupPlan, + stop_container: bool, + recovery: RecoveryGuard, +) -> Result<(), std::io::Error> { + cleanup_resources(runner, plan, stop_container).map_err(cli_io_error)?; + recovery + .complete() + .map_err(|error| std::io::Error::other(error.to_string())) +} + +fn recover_stale_resources(runner: &dyn CommandRunner) -> Result<(), std::io::Error> { + for stale in stale_recoveries().map_err(|error| std::io::Error::other(error.to_string()))? { + cleanup_resources(runner, stale.cleanup_plan(), true).map_err(cli_io_error)?; + stale + .complete() + .map_err(|error| std::io::Error::other(error.to_string()))?; + } + Ok(()) +} + +fn cleanup_resource( + runner: &dyn CommandRunner, + resource: &OwnedResource, + stop_container: bool, +) -> Result<(), CliError> { + if !verify_ownership(runner, resource)? { + return Ok(()); + } + if stop_container && resource.name.kind() == ResourceKind::Container { + run_checked(runner, &stop_container_command(resource))?; + if !verify_ownership(runner, resource)? { + return Ok(()); + } + } + run_checked(runner, &delete_command(resource))?; + Ok(()) +} + +struct SecureEnvironmentFile { + path: PathBuf, +} + +impl SecureEnvironmentFile { + fn create(environment: &[String]) -> std::io::Result> { + if environment.is_empty() { + return Ok(None); + } + for _ in 0..8 { + let mut random = [0u8; 8]; + getrandom::getrandom(&mut random) + .map_err(|error| std::io::Error::other(error.to_string()))?; + let path = std::env::temp_dir().join(format!( + "mxc-apple-container-env-{:016x}", + u64::from_ne_bytes(random) + )); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + for entry in environment { + writeln!(file, "{entry}")?; + } + file.flush()?; + return Ok(Some(Self { path })); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create a unique Apple Container environment file", + )) + } +} + +impl Drop for SecureEnvironmentFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn cli_io_error(error: CliError) -> std::io::Error { + std::io::Error::other(format!("Apple Container cleanup failed: {error}")) +} + +fn stdio_is_tty() -> bool { + // SAFETY: `isatty` only inspects the two standard file descriptors. + unsafe { libc::isatty(libc::STDIN_FILENO) == 1 && libc::isatty(libc::STDOUT_FILENO) == 1 } +} + +fn rejected(message: String) -> ScriptResponse { + error_response(message, FailurePhase::Rejected) +} + +fn backend_unavailable(message: String) -> ScriptResponse { + error_response(message, FailurePhase::BackendUnavailable) +} + +fn launch_failed(message: String) -> ScriptResponse { + error_response(message, FailurePhase::LaunchFailed) +} + +fn error_response(message: String, failure_phase: FailurePhase) -> ScriptResponse { + ScriptResponse { + exit_code: -1, + standard_err: message.clone(), + error_message: message, + failure_phase, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::{CommandError, CommandErrorKind, CommandOutput}; + use crate::resource::{OwnershipToken, ResourceNames}; + + struct FailingRunner; + + impl CommandRunner for FailingRunner { + fn run( + &self, + _command: &crate::command::CliCommand, + ) -> Result { + Err(CommandError::new( + CommandErrorKind::Wait, + "simulated management failure", + )) + } + } + + #[test] + fn management_failure_still_terminates_local_cli_child() { + let child = Command::new("/bin/sleep").arg("60").spawn().unwrap(); + let token = OwnershipToken::parse("0123456789abcdef0123456789abcdef").unwrap(); + let names = ResourceNames::new("termination-test", &token); + let mut process = AppleContainerProcess { + child, + stdin: None, + stdout: None, + stderr: None, + stdout_canceller: None, + stderr_canceller: None, + timeout: None, + grouped: false, + cleanup: Some(CleanupPlan { + container: OwnedResource::container(names.container, &token), + network: None, + }), + environment: None, + recovery: None, + runner: Arc::new(FailingRunner), + }; + + assert!(process.terminate_owned().is_err()); + assert!(wait_with_timeout(&mut process.child, Some(Duration::from_secs(1))).is_ok()); + process.cleanup = None; + } +} diff --git a/src/backends/apple_container/common/tests/runtime_integration.rs b/src/backends/apple_container/common/tests/runtime_integration.rs new file mode 100644 index 000000000..bf5644849 --- /dev/null +++ b/src/backends/apple_container/common/tests/runtime_integration.rs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(target_os = "macos")] + +use std::io::{Read, Write}; + +use apple_container_common::AppleContainerBackend; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ + AppleContainerConfig, ContainmentBackend, ExecutionRequest, ExperimentalConfig, NetworkPolicy, +}; +use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; + +#[test] +#[ignore = "requires Apple Container 1.2.2 and the alpine:3.22 image"] +fn streams_stdin_stdout_stderr_and_propagates_exit_code() { + let request = ExecutionRequest { + container_id: "runtime-integration".to_string(), + script_code: + "read value; printf 'stdout:%s\\n' \"$value\"; printf 'stderr:%s\\n' \"$value\" >&2; exit 9" + .to_string(), + containment: ContainmentBackend::AppleContainer, + script_timeout: 30_000, + experimental_enabled: true, + experimental: ExperimentalConfig { + apple_container: Some(AppleContainerConfig { + image: "docker.io/library/alpine:3.22".to_string(), + cpu_count: Some(1), + memory_mb: Some(512), + }), + ..Default::default() + }, + policy: wxc_common::models::ContainerPolicy { + default_network_policy: NetworkPolicy::Allow, + ..Default::default() + }, + ..Default::default() + }; + let mut backend = AppleContainerBackend::new(); + let mut logger = Logger::new(Mode::Buffer); + let mut process = backend + .spawn(&request, &mut logger, StdioMode::Pipes) + .expect("spawn Apple Container"); + + let mut stdin = process.take_stdin().expect("stdin pipe"); + stdin.write_all(b"stream-value\n").expect("write stdin"); + drop(stdin); + + let mut stdout = process.take_stdout().expect("stdout pipe"); + let mut stderr = process.take_stderr().expect("stderr pipe"); + let stdout_thread = std::thread::spawn(move || { + let mut output = String::new(); + stdout.read_to_string(&mut output).expect("read stdout"); + output + }); + let stderr_thread = std::thread::spawn(move || { + let mut output = String::new(); + stderr.read_to_string(&mut output).expect("read stderr"); + output + }); + + assert_eq!(process.wait().expect("wait"), 9); + assert_eq!( + stdout_thread.join().expect("stdout thread"), + "stdout:stream-value\n" + ); + assert_eq!( + stderr_thread.join().expect("stderr thread"), + "stderr:stream-value\n" + ); +} diff --git a/src/backends/apple_container/init/Containerfile b/src/backends/apple_container/init/Containerfile new file mode 100644 index 000000000..2fd569341 --- /dev/null +++ b/src/backends/apple_container/init/Containerfile @@ -0,0 +1,11 @@ +FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce AS firewall +RUN apk add --no-cache iptables + +FROM ghcr.io/apple/containerization/vminit:0.40.1@sha256:8a097617a16ec70420bf452bd17c219ca4fde5e5d5d9b3e9c50e0f8a1d3913a2 +COPY --from=firewall /bin /bin +COPY --from=firewall /lib /lib +COPY --from=firewall /sbin /sbin +COPY --from=firewall /usr /usr +RUN mv /sbin/vminitd /sbin/vminitd.real +COPY vminitd-wrapper.sh /sbin/vminitd +RUN chmod 0755 /sbin/vminitd diff --git a/src/backends/apple_container/init/build.sh b/src/backends/apple_container/init/build.sh new file mode 100755 index 000000000..21a6f8dba --- /dev/null +++ b/src/backends/apple_container/init/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly CONTAINER_BIN="/usr/local/bin/container" +readonly IMAGE="local/mxc-loopback-init:0.2" +readonly EXPECTED_DIGEST="sha256:a82bc45e6fee26927b9881150ca2d8d1b29969a306ba579e8b8887345d31dc2f" +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +[ -x "$CONTAINER_BIN" ] || { + echo "Apple Container CLI not found at $CONTAINER_BIN" >&2 + exit 1 +} + +"$CONTAINER_BIN" build --tag "$IMAGE" "$SCRIPT_DIR" +actual_digest="$( + "$CONTAINER_BIN" image inspect "$IMAGE" | + sed -n 's/.*"digest" : "\(sha256:[0-9a-f]*\)".*/\1/p' | + head -n 1 +)" + +if [ "$actual_digest" != "$EXPECTED_DIGEST" ]; then + echo "Unexpected init image digest: $actual_digest (expected $EXPECTED_DIGEST)" >&2 + exit 1 +fi + +echo "$IMAGE verified at $EXPECTED_DIGEST" diff --git a/src/backends/apple_container/init/vminitd-wrapper.sh b/src/backends/apple_container/init/vminitd-wrapper.sh new file mode 100644 index 000000000..4bc8c2b2d --- /dev/null +++ b/src/backends/apple_container/init/vminitd-wrapper.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +log() { + printf '<6>mxc-init: %s\n' "$*" > /dev/kmsg +} + +install_family_policy() { + command="$1" + + "$command" -w -F + "$command" -w -X + "$command" -w -P INPUT DROP + "$command" -w -P FORWARD DROP + "$command" -w -P OUTPUT DROP + "$command" -w -A INPUT -i lo -j ACCEPT + "$command" -w -A OUTPUT -o lo -j ACCEPT +} + +log "installing loopback-only IPv4 and IPv6 policy" +install_family_policy /usr/sbin/iptables +install_family_policy /usr/sbin/ip6tables +log "network policy installed" + +exec /sbin/vminitd.real "$@" diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index e5f466500..4e076f391 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -7,7 +7,8 @@ //! log-file). On macOS it dispatches to `SeatbeltScriptRunner`; on every //! other platform it prints an explanatory error and exits 1 — that way //! `cargo build -p mxc_darwin` succeeds in CI from any host, while runtime -//! use still requires macOS. +//! use still requires macOS. Seatbelt remains the default backend; explicit +//! experimental Apple Container requests dispatch through the same binary. use std::fmt::Write; use std::process; @@ -120,11 +121,11 @@ fn main() { log_request(&request, &mut logger); - run_seatbelt(&request, &mut logger); + run_backend(&request, &mut logger); } #[cfg(target_os = "macos")] -fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { +fn run_backend(request: &ExecutionRequest, logger: &mut Logger) -> ! { use wxc_common::telemetry; // ── Telemetry init (experimental) ─────────────────────────────── @@ -152,8 +153,8 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { } // Backend selection lives in `mxc_engine::run`, the single home for one-shot - // dispatch. On macOS it always resolves to Seatbelt (logging a note if the - // request asked for a different backend) and runs it to completion. + // dispatch. Seatbelt remains the default while an explicit Apple Container + // request selects that experimental backend without changing executables. let run_start = Instant::now(); let response = match mxc_engine::run(request, logger) { Ok(response) => response, @@ -193,7 +194,7 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { } #[cfg(not(target_os = "macos"))] -fn run_seatbelt(_request: &ExecutionRequest, logger: &mut Logger) -> ! { +fn run_backend(_request: &ExecutionRequest, logger: &mut Logger) -> ! { eprintln!( "mxc-exec-mac: the macOS sandbox backend is only available on macOS. \ This binary was built for a non-Darwin target and cannot execute scripts." diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index acac63fa0..c24eced17 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -70,16 +70,7 @@ pub fn spawn_runner( ContainmentBackend::Bubblewrap => spawn_bubblewrap(request, logger), ContainmentBackend::ProcessContainer => spawn_process_container(request, logger), ContainmentBackend::Wslc => spawn_wslc(request, logger), - ContainmentBackend::AppleContainer => { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "Apple Container is an experimental feature. Use --experimental flag.", - )); - } - Err(MxcError::unsupported_containment( - "Apple Container configuration is recognized, but streaming execution is not implemented", - )) - } + ContainmentBackend::AppleContainer => spawn_apple_container(request, logger), other => Err(MxcError::unsupported_containment(format!( "the mxc engine does not yet support streaming for the '{}' backend", other.wire_name() @@ -87,6 +78,38 @@ pub fn spawn_runner( } } +#[cfg(target_os = "macos")] +fn spawn_apple_container( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result, MxcError> { + use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; + if !request.experimental_enabled { + return Err(MxcError::malformed_request( + "Apple Container is an experimental feature. Use --experimental flag.", + )); + } + let mut runner = apple_container_common::AppleContainerBackend::new(); + runner + .spawn(request, logger, StdioMode::Pipes) + .map_err(map_spawn_error) +} + +#[cfg(not(target_os = "macos"))] +fn spawn_apple_container( + request: &ExecutionRequest, + _logger: &mut Logger, +) -> Result, MxcError> { + if !request.experimental_enabled { + return Err(MxcError::malformed_request( + "Apple Container is an experimental feature. Use --experimental flag.", + )); + } + Err(MxcError::unsupported_containment( + "Apple Container is only available on macOS", + )) +} + /// Map a backend's `spawn` failure `ScriptResponse` to an /// [`MxcError`], preserving the `BackendUnavailable` phase (so callers can fall /// back to a lower tier) and folding any `extended_error` detail into the @@ -104,6 +127,7 @@ fn map_spawn_error(resp: ScriptResponse) -> MxcError { } match resp.failure_phase { FailurePhase::BackendUnavailable => MxcError::backend_unavailable(message), + FailurePhase::Rejected => MxcError::policy_validation(message), _ => MxcError::backend_error(message), } } @@ -306,7 +330,8 @@ mod tests { } #[test] - fn streaming_apple_container_is_gated_then_unimplemented() { + #[cfg(not(target_os = "macos"))] + fn streaming_apple_container_is_gated_then_rejected_off_macos() { let mut request = build_request(&minimal_policy(), None).expect("build_request"); request.inner.containment = ContainmentBackend::AppleContainer; let mut logger = Logger::new(Mode::Buffer); @@ -319,7 +344,7 @@ mod tests { request.inner.experimental_enabled = true; let error = match spawn_runner(&request.inner, &mut logger) { - Ok(_) => panic!("Apple Container streaming must remain unimplemented"), + Ok(_) => panic!("Apple Container must be rejected off macOS"), Err(error) => error, }; assert_eq!(error.code, MxcErrorCode::UnsupportedContainment); diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 6fa5ff3e9..07f0cd0c1 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -38,17 +38,22 @@ pub struct PlatformSupport { pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "macos")] { + let mut available_methods = Vec::new(); if std::path::Path::new("/usr/bin/sandbox-exec").exists() { + available_methods.push("seatbelt".to_string()); + } + if apple_container_common::is_available() { + available_methods.push("apple_container".to_string()); + } + if available_methods.is_empty() { PlatformSupport { - is_supported: true, - available_methods: vec!["seatbelt".to_string()], + reason: Some("no supported macOS containment backend is available".to_string()), ..Default::default() } } else { PlatformSupport { - reason: Some( - "/usr/bin/sandbox-exec not found; macOS install is incomplete".to_string(), - ), + is_supported: true, + available_methods, ..Default::default() } } @@ -160,6 +165,7 @@ mod tests { Containment::Seatbelt, Containment::IsolationSession, Containment::Bubblewrap, + Containment::AppleContainer, ] .iter() .map(wire_name) @@ -178,6 +184,7 @@ mod tests { ); assert_eq!(wire_name(&Containment::Bubblewrap), "bubblewrap"); assert_eq!(wire_name(&Containment::Seatbelt), "seatbelt"); + assert_eq!(wire_name(&Containment::AppleContainer), "apple_container"); } /// Exercises the live per-target arm, catching a typo'd literal (e.g. diff --git a/src/core/mxc_engine/src/probe.rs b/src/core/mxc_engine/src/probe.rs index b8caf2f03..a69a68fba 100644 --- a/src/core/mxc_engine/src/probe.rs +++ b/src/core/mxc_engine/src/probe.rs @@ -89,6 +89,11 @@ fn macos_backends() -> Vec { ContainmentBackend::Seatbelt.wire_name(), )); } + if apple_container_common::is_available() { + backends.push(AvailableBackend::tierless( + ContainmentBackend::AppleContainer.wire_name(), + )); + } backends } @@ -204,6 +209,7 @@ mod tests { Containment::Seatbelt, Containment::IsolationSession, Containment::Bubblewrap, + Containment::AppleContainer, ] .iter() .map(wire_name) @@ -263,7 +269,7 @@ mod tests { /// from `ContainmentBackend` (the same source as the `push` calls) so the /// emitted names can't be typo'd, and checked against the `wire::Containment` /// serde names so the two enums can't drift. - const EMITTABLE_BACKENDS: [ContainmentBackend; 7] = [ + const EMITTABLE_BACKENDS: [ContainmentBackend; 8] = [ ContainmentBackend::Seatbelt, ContainmentBackend::Bubblewrap, ContainmentBackend::Lxc, @@ -271,6 +277,7 @@ mod tests { ContainmentBackend::WindowsSandbox, ContainmentBackend::Wslc, ContainmentBackend::IsolationSession, + ContainmentBackend::AppleContainer, ]; /// Complements [`every_reported_backend_is_a_real_wire_name`] (host subset) diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index 4c9a99d9c..330483154 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -343,7 +343,14 @@ fn resolve_runner_inner( use wxc_common::sandbox_process::Runner; if request.containment == ContainmentBackend::AppleContainer { - return reject_unimplemented_apple_container(request); + if !request.experimental_enabled { + return Err(MxcError::malformed_request( + "Apple Container is an experimental feature. Use --experimental flag.", + )); + } + return Ok(ResolvedRunner::without_guard(Box::new(Runner::new( + apple_container_common::AppleContainerBackend::new(), + )))); } if request.containment != ContainmentBackend::Seatbelt { logger.log_line("Note: Overriding containment backend to Seatbelt on macOS."); @@ -353,6 +360,7 @@ fn resolve_runner_inner( )))) } +#[cfg(any(target_os = "windows", target_os = "linux"))] fn reject_unimplemented_apple_container( request: &ExecutionRequest, ) -> Result { @@ -391,21 +399,25 @@ mod macos_tests { } #[test] - fn apple_container_does_not_fall_back_to_seatbelt() { + fn apple_container_resolves_without_falling_back_to_seatbelt() { let request = ExecutionRequest { containment: ContainmentBackend::AppleContainer, experimental_enabled: true, + script_code: "true".to_string(), + experimental: wxc_common::models::ExperimentalConfig { + apple_container: Some(wxc_common::models::AppleContainerConfig { + image: "alpine:3.22".to_string(), + cpu_count: None, + memory_mb: None, + }), + ..Default::default() + }, ..Default::default() }; let mut logger = Logger::new(Mode::Buffer); - let error = match resolve_runner_inner(&request, &mut logger) { - Ok(_) => panic!("Apple Container must not fall back to Seatbelt"), - Err(error) => error, - }; - - assert_eq!(error.code, MxcErrorCode::UnsupportedContainment); - assert!(error.message.contains("not implemented")); + assert!(resolve_runner_inner(&request, &mut logger).is_ok()); + assert!(logger.get_buffer().is_empty()); } } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 0af553cca..5b89ea16d 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -1006,6 +1006,7 @@ fn convert_wire_config( normalize_filesystem_paths(&mut policy, logger); // Fallback section + policy.fallback_specified = cfg.fallback.is_some(); if let Some(fbcfg) = cfg.fallback { if let Some(v) = fbcfg.allow_dacl_mutation { policy.fallback.allow_dacl_mutation = v; @@ -1018,6 +1019,7 @@ fn convert_wire_config( // to `Block` either way). policy.network_specified = cfg.network.is_some(); if let Some(net) = cfg.network { + policy.network_enforcement_mode_specified = net.enforcement_mode.is_some(); // Presence of any network *mode* field (everything except `proxy`), so // post-provision phases can reject an immutable-posture change by // presence while still accepting a proxy-only network block. @@ -3259,6 +3261,32 @@ mod tests { assert!(!req.policy.network_specified); } + #[test] + fn enforcement_mode_presence_is_preserved_even_at_its_default() { + let json = r#"{ + "process": {"commandLine": "echo x"}, + "network": {"enforcementMode": "capabilities"} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.network_enforcement_mode_specified); + } + + #[test] + fn fallback_presence_is_preserved_even_when_empty() { + let json = r#"{ + "process": {"commandLine": "echo x"}, + "fallback": {} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.fallback_specified); + } + #[test] fn ui_specified_true_when_ui_present() { // An empty `ui: {}` still counts as "supplied" — the twin of diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 3a83d076c..a54a28131 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -624,8 +624,14 @@ pub struct ContainerPolicy { pub readonly_paths: Vec, pub denied_paths: Vec, pub fallback: FallbackPolicy, + /// Whether the caller supplied the Windows-only `fallback` block. + #[serde(skip)] + pub fallback_specified: bool, pub default_network_policy: NetworkPolicy, pub network_enforcement_mode: NetworkEnforcementMode, + /// Whether the caller explicitly supplied `network.enforcementMode`. + #[serde(skip)] + pub network_enforcement_mode_specified: bool, /// When true, the sandboxed process may bind() + listen() on local IPs /// and accept incoming connections. Independent of `default_network_policy` /// (which governs outbound traffic).