diff --git a/src/Cargo.lock b/src/Cargo.lock index 45fa4991d..43b7b63bd 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -103,6 +103,17 @@ dependencies = [ "wxc_common", ] +[[package]] +name = "apple_container_common" +version = "0.7.0" +dependencies = [ + "getrandom 0.2.17", + "serde", + "serde_json", + "thiserror", + "wxc_common", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1526,6 +1537,7 @@ name = "mxc_engine" version = "0.7.0" dependencies = [ "appcontainer_common", + "apple_container_common", "bwrap_common", "hyperlight_common", "isolation_session_bindings", diff --git a/src/Cargo.toml b/src/Cargo.toml index 85f0e8bea..01cde2595 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "backends/apple_container/common", "backends/appcontainer/common", "backends/bubblewrap/common", "backends/hyperlight/common", @@ -63,6 +64,7 @@ license = "MIT" [workspace.dependencies] anyhow = "1" +apple_container_common = { path = "backends/apple_container/common" } appcontainer_common = { path = "backends/appcontainer/common" } base64 = "0.22" bwrap_common = { path = "backends/bubblewrap/common" } diff --git a/src/backends/apple_container/common/Cargo.toml b/src/backends/apple_container/common/Cargo.toml new file mode 100644 index 000000000..3e0bc1d72 --- /dev/null +++ b/src/backends/apple_container/common/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "apple_container_common" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Typed Apple Container management primitives for the MXC macOS backend." + +[dependencies] +getrandom = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +wxc_common = { workspace = true } diff --git a/src/backends/apple_container/common/src/availability.rs b/src/backends/apple_container/common/src/availability.rs new file mode 100644 index 000000000..4238088e2 --- /dev/null +++ b/src/backends/apple_container/common/src/availability.rs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::path::Path; + +use serde::Deserialize; +use thiserror::Error; +use wxc_common::mxc_error::MxcError; + +use crate::command::{CliArgument, CliCommand, CommandOutput, CommandRunner}; + +/// Fixed path where Apple's installer places the Container CLI. +pub const APPLE_CONTAINER_CLI_PATH: &str = "/usr/local/bin/container"; + +/// Official Apple Container release downloads. +pub const APPLE_CONTAINER_RELEASES_URL: &str = "https://github.com/apple/container/releases"; + +/// Exact CLI and API server version qualified by this prototype. +pub const QUALIFIED_APPLE_CONTAINER_VERSION: AppleContainerVersion = + AppleContainerVersion::new(1, 2, 2); + +/// Parsed Apple Container semantic version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppleContainerVersion { + pub major: u64, + pub minor: u64, + pub patch: u64, +} + +impl AppleContainerVersion { + pub const fn new(major: u64, minor: u64, patch: u64) -> Self { + Self { + major, + minor, + patch, + } + } +} + +impl std::fmt::Display for AppleContainerVersion { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// Host facts needed by the Apple Container availability gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostInfo { + pub operating_system: String, + pub architecture: String, + pub macos_major_version: Option, + pub cli_present: bool, +} + +impl HostInfo { + /// Capture compile-time OS/architecture and caller-supplied macOS version. + pub fn current(macos_major_version: Option) -> Self { + Self { + operating_system: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + macos_major_version, + cli_present: Path::new(APPLE_CONTAINER_CLI_PATH).is_file(), + } + } +} + +/// Documented JSON returned by `container system status --format json`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SystemStatus { + pub api_server_app_name: String, + pub api_server_build: String, + pub api_server_commit: String, + pub api_server_version: String, + pub app_root: String, + pub install_root: String, + pub status: String, +} + +/// Successful Apple Container availability details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppleContainerAvailability { + pub cli_version: AppleContainerVersion, + pub api_server_version: AppleContainerVersion, + pub api_server_commit: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AvailabilityError { + #[error("Apple Container is available only on Apple silicon macOS 26 or newer")] + UnsupportedHost, + #[error( + "Apple Container CLI was not found at /usr/local/bin/container. Install Apple Container from https://github.com/apple/container/releases" + )] + CliMissing, + #[error("Apple Container availability command failed: {0}")] + CommandFailed(String), + #[error("Apple Container returned malformed availability data: {0}")] + MalformedOutput(String), + #[error( + "Apple Container version {actual} is not qualified; MXC currently requires version {required}" + )] + UnsupportedVersion { + actual: AppleContainerVersion, + required: AppleContainerVersion, + }, + #[error("Apple Container service is not running (reported status: {0})")] + ServiceNotRunning(String), + #[error("Apple Container CLI version {cli} does not match API server version {api_server}")] + VersionMismatch { + cli: AppleContainerVersion, + api_server: AppleContainerVersion, + }, +} + +impl AvailabilityError { + /// Map availability failures to the public typed backend error. + pub fn into_mxc_error(self) -> MxcError { + MxcError::backend_unavailable(self.to_string()) + } +} + +/// Check only the fixed CLI installation path without invoking the executable. +pub fn check_cli_installed() -> Result<(), AvailabilityError> { + check_cli_presence(Path::new(APPLE_CONTAINER_CLI_PATH).is_file()) +} + +fn check_cli_presence(present: bool) -> Result<(), AvailabilityError> { + if present { + Ok(()) + } else { + Err(AvailabilityError::CliMissing) + } +} + +/// Probe Apple Container using an injected command runner. +pub fn probe_with( + runner: &dyn CommandRunner, + host: &HostInfo, +) -> Result { + validate_host(host)?; + check_cli_presence(host.cli_present)?; + + let version_output = + run_probe_command(runner, CliCommand::new([CliArgument::literal("--version")]))?; + let cli_version = parse_version_output(&version_output)?; + require_qualified_version(cli_version)?; + + let status_output = run_probe_command( + runner, + CliCommand::new([ + CliArgument::literal("system"), + CliArgument::literal("status"), + CliArgument::literal("--format"), + CliArgument::literal("json"), + ]), + )?; + let status: SystemStatus = serde_json::from_slice(&status_output.stdout) + .map_err(|error| AvailabilityError::MalformedOutput(error.to_string()))?; + if status.status != "running" { + return Err(AvailabilityError::ServiceNotRunning(status.status)); + } + if status.api_server_app_name != "container-apiserver" { + return Err(AvailabilityError::MalformedOutput(format!( + "unexpected API server identity {:?}", + status.api_server_app_name + ))); + } + let api_server_version = parse_version_text(&status.api_server_version)?; + if cli_version != api_server_version { + return Err(AvailabilityError::VersionMismatch { + cli: cli_version, + api_server: api_server_version, + }); + } + require_qualified_version(api_server_version)?; + + Ok(AppleContainerAvailability { + cli_version, + api_server_version, + api_server_commit: status.api_server_commit, + }) +} + +fn validate_host(host: &HostInfo) -> Result<(), AvailabilityError> { + if host.operating_system != "macos" + || host.architecture != "aarch64" + || host.macos_major_version.is_none_or(|major| major < 26) + { + return Err(AvailabilityError::UnsupportedHost); + } + Ok(()) +} + +fn run_probe_command( + runner: &dyn CommandRunner, + command: CliCommand, +) -> Result { + let diagnostic = command.diagnostic(); + let output_limit = command.output_limit(); + let output = runner + .run(&command) + .map_err(|error| AvailabilityError::CommandFailed(error.to_string()))?; + if output.stdout.len().saturating_add(output.stderr.len()) > output_limit { + return Err(AvailabilityError::MalformedOutput(format!( + "{diagnostic} output exceeded the {output_limit}-byte limit" + ))); + } + if !output.success() { + return Err(AvailabilityError::CommandFailed(format!( + "{diagnostic} exited with status {:?}: {}", + output.exit_code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(output) +} + +fn parse_version_output( + output: &CommandOutput, +) -> Result { + let text = std::str::from_utf8(&output.stdout) + .map_err(|error| AvailabilityError::MalformedOutput(error.to_string()))?; + parse_version_text(text) +} + +fn parse_version_text(text: &str) -> Result { + let token = text + .split_whitespace() + .skip_while(|token| *token != "version") + .nth(1) + .ok_or_else(|| { + AvailabilityError::MalformedOutput( + "version output did not contain a 'version X.Y.Z' marker".to_string(), + ) + })?; + let components = token + .split('.') + .map(str::parse::) + .collect::, _>>() + .map_err(|_| { + AvailabilityError::MalformedOutput(format!( + "invalid Apple Container version token {token:?}" + )) + })?; + match components.as_slice() { + [major, minor, patch] => Ok(AppleContainerVersion::new(*major, *minor, *patch)), + _ => Err(AvailabilityError::MalformedOutput(format!( + "invalid Apple Container version token {token:?}" + ))), + } +} + +fn require_qualified_version(version: AppleContainerVersion) -> Result<(), AvailabilityError> { + if version == QUALIFIED_APPLE_CONTAINER_VERSION { + Ok(()) + } else { + Err(AvailabilityError::UnsupportedVersion { + actual: version, + required: QUALIFIED_APPLE_CONTAINER_VERSION, + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + + use super::*; + use crate::command::{CommandError, CommandErrorKind}; + + struct FakeRunner { + results: Mutex>>, + diagnostics: Mutex>, + } + + impl FakeRunner { + fn new(results: impl IntoIterator>) -> Self { + Self { + results: Mutex::new(results.into_iter().collect()), + diagnostics: Mutex::new(Vec::new()), + } + } + } + + impl CommandRunner for FakeRunner { + fn run(&self, command: &CliCommand) -> Result { + self.diagnostics.lock().unwrap().push(command.diagnostic()); + self.results + .lock() + .unwrap() + .pop_front() + .expect("fake command result") + } + } + + fn supported_host() -> HostInfo { + HostInfo { + operating_system: "macos".to_string(), + architecture: "aarch64".to_string(), + macos_major_version: Some(26), + cli_present: true, + } + } + + fn success(stdout: &str) -> Result { + Ok(CommandOutput { + exit_code: Some(0), + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + }) + } + + fn running_status() -> &'static str { + r#"{"apiServerAppName":"container-apiserver","apiServerBuild":"release","apiServerCommit":"0190097d06df0b9065f4c2d2c7873c649d81d493","apiServerVersion":"container-apiserver version 1.2.2 (build: release, commit: 0190097)","appRoot":"/tmp/apple-container","installRoot":"/usr/local/","status":"running"}"# + } + + #[test] + fn probe_accepts_qualified_running_service() { + let runner = FakeRunner::new([ + success("container CLI version 1.2.2 (build: release, commit: 0190097)"), + success(running_status()), + ]); + + let availability = probe_with(&runner, &supported_host()).unwrap(); + + assert_eq!(availability.cli_version, QUALIFIED_APPLE_CONTAINER_VERSION); + assert_eq!( + runner.diagnostics.lock().unwrap().as_slice(), + [ + "/usr/local/bin/container --version", + "/usr/local/bin/container system status --format json" + ] + ); + } + + #[test] + fn missing_cli_error_is_actionable_and_typed() { + let host = HostInfo { + cli_present: false, + ..supported_host() + }; + let runner = FakeRunner::new([]); + + let error = probe_with(&runner, &host).unwrap_err(); + let message = error.to_string(); + + assert!(matches!(error, AvailabilityError::CliMissing)); + assert!(message.contains(APPLE_CONTAINER_CLI_PATH)); + assert!(message.contains(APPLE_CONTAINER_RELEASES_URL)); + assert_eq!( + AvailabilityError::CliMissing.into_mxc_error().code, + wxc_common::mxc_error::MxcErrorCode::BackendUnavailable + ); + } + + #[test] + fn probe_rejects_unsupported_host_before_commands() { + let runner = FakeRunner::new([]); + let host = HostInfo { + architecture: "x86_64".to_string(), + ..supported_host() + }; + + assert!(matches!( + probe_with(&runner, &host), + Err(AvailabilityError::UnsupportedHost) + )); + } + + #[test] + fn probe_rejects_wrong_version_and_stopped_service() { + let wrong_version = FakeRunner::new([success("container CLI version 1.3.0")]); + assert!(matches!( + probe_with(&wrong_version, &supported_host()), + Err(AvailabilityError::UnsupportedVersion { .. }) + )); + + let stopped_status = running_status().replace("\"running\"", "\"stopped\""); + let stopped = FakeRunner::new([ + success("container CLI version 1.2.2"), + success(&stopped_status), + ]); + assert!(matches!( + probe_with(&stopped, &supported_host()), + Err(AvailabilityError::ServiceNotRunning(status)) if status == "stopped" + )); + } + + #[test] + fn probe_rejects_mismatched_server_version() { + let mismatched_status = running_status().replace("version 1.2.2", "version 1.2.1"); + let runner = FakeRunner::new([ + success("container CLI version 1.2.2"), + success(&mismatched_status), + ]); + + assert!(matches!( + probe_with(&runner, &supported_host()), + Err(AvailabilityError::VersionMismatch { + cli: AppleContainerVersion { + major: 1, + minor: 2, + patch: 2 + }, + api_server: AppleContainerVersion { + major: 1, + minor: 2, + patch: 1 + } + }) + )); + } + + #[test] + fn probe_rejects_malformed_and_oversized_output() { + let malformed = + FakeRunner::new([success("container CLI release without a version marker")]); + assert!(matches!( + probe_with(&malformed, &supported_host()), + Err(AvailabilityError::MalformedOutput(_)) + )); + + let oversized = FakeRunner::new([success( + &"x".repeat(crate::command::DEFAULT_COMMAND_OUTPUT_LIMIT + 1), + )]); + let error = probe_with(&oversized, &supported_host()).unwrap_err(); + assert!(matches!(error, AvailabilityError::MalformedOutput(_))); + assert!(error.to_string().contains("output exceeded")); + } + + #[test] + fn runner_failure_is_preserved_without_invoking_later_commands() { + let runner = FakeRunner::new([Err(CommandError::new( + CommandErrorKind::Timeout, + "version probe timed out", + ))]); + + let error = probe_with(&runner, &supported_host()).unwrap_err(); + + assert!(matches!(error, AvailabilityError::CommandFailed(_))); + assert!(error.to_string().contains("timed out")); + } +} diff --git a/src/backends/apple_container/common/src/command.rs b/src/backends/apple_container/common/src/command.rs new file mode 100644 index 000000000..8bb6c5784 --- /dev/null +++ b/src/backends/apple_container/common/src/command.rs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ffi::{OsStr, OsString}; +use std::time::Duration; + +use thiserror::Error; + +use crate::availability::APPLE_CONTAINER_CLI_PATH; + +/// Default timeout for read-only Apple Container management commands. +pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum combined stdout/stderr retained from a management command. +pub const DEFAULT_COMMAND_OUTPUT_LIMIT: usize = 64 * 1024; + +/// One Apple Container CLI argument and its diagnostic sensitivity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliArgument { + value: OsString, + sensitive: bool, +} + +impl CliArgument { + /// Construct a normal argument that may be rendered in diagnostics. + pub fn literal(value: impl Into) -> Self { + Self { + value: value.into(), + sensitive: false, + } + } + + /// Construct an argument whose value must be redacted in diagnostics. + pub fn sensitive(value: impl Into) -> Self { + Self { + value: value.into(), + sensitive: true, + } + } + + /// The exact argument supplied to the child process. + pub fn value(&self) -> &OsStr { + &self.value + } + + fn diagnostic_value(&self) -> String { + if self.sensitive { + "".to_string() + } else { + self.value.to_string_lossy().into_owned() + } + } +} + +/// A bounded invocation of the fixed Apple Container CLI executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliCommand { + arguments: Vec, + timeout: Duration, + output_limit: usize, +} + +impl CliCommand { + /// Construct a command with the management defaults. + pub fn new(arguments: impl IntoIterator) -> Self { + Self { + arguments: arguments.into_iter().collect(), + timeout: DEFAULT_COMMAND_TIMEOUT, + output_limit: DEFAULT_COMMAND_OUTPUT_LIMIT, + } + } + + /// Override the command timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Override the combined stdout/stderr retention limit. + pub fn with_output_limit(mut self, output_limit: usize) -> Self { + self.output_limit = output_limit; + self + } + + /// The fixed external executable path. + pub fn program(&self) -> &'static OsStr { + OsStr::new(APPLE_CONTAINER_CLI_PATH) + } + + /// Exact child-process arguments, preserving argument boundaries. + pub fn arguments(&self) -> impl Iterator { + self.arguments.iter().map(CliArgument::value) + } + + /// Maximum runtime for this helper command. + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Maximum combined stdout/stderr retained by the runner. + pub fn output_limit(&self) -> usize { + self.output_limit + } + + /// Shell-free, redacted diagnostic rendering. + pub fn diagnostic(&self) -> String { + std::iter::once(APPLE_CONTAINER_CLI_PATH.to_string()) + .chain(self.arguments.iter().map(CliArgument::diagnostic_value)) + .collect::>() + .join(" ") + } +} + +/// Captured output from a bounded CLI invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandOutput { + /// Process exit code, or `None` when terminated by a signal. + pub exit_code: Option, + /// Captured stdout bytes. + pub stdout: Vec, + /// Captured stderr bytes. + pub stderr: Vec, +} + +impl CommandOutput { + /// Whether the CLI exited successfully. + pub fn success(&self) -> bool { + self.exit_code == Some(0) + } +} + +/// Category of command-runner failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandErrorKind { + Spawn, + Timeout, + OutputLimit, + Wait, +} + +/// Failure before a usable CLI result was obtained. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("{message}")] +pub struct CommandError { + /// Stable failure category. + pub kind: CommandErrorKind, + /// Redacted diagnostic message. + pub message: String, +} + +impl CommandError { + /// Construct a command error whose message contains no secret arguments. + pub fn new(kind: CommandErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +/// Injectable boundary around external Apple Container CLI execution. +pub trait CommandRunner: Send + Sync { + /// Execute one bounded command without a host shell. + fn run(&self, command: &CliCommand) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_preserves_arguments_and_redacts_sensitive_values() { + let command = CliCommand::new([ + CliArgument::literal("run"), + CliArgument::literal("--env-file"), + CliArgument::sensitive("/private/tmp/mxc-secret-env"), + ]); + + let arguments = command + .arguments() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + arguments, + ["run", "--env-file", "/private/tmp/mxc-secret-env"] + ); + assert_eq!( + command.diagnostic(), + "/usr/local/bin/container run --env-file " + ); + assert_eq!(command.timeout(), DEFAULT_COMMAND_TIMEOUT); + assert_eq!(command.output_limit(), DEFAULT_COMMAND_OUTPUT_LIMIT); + } +} diff --git a/src/backends/apple_container/common/src/lib.rs b/src/backends/apple_container/common/src/lib.rs new file mode 100644 index 000000000..9e85ce862 --- /dev/null +++ b/src/backends/apple_container/common/src/lib.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Typed management primitives for MXC's experimental Apple Container backend. +//! +//! This crate deliberately does not execute workloads. It defines the boundary +//! around Apple's external CLI, availability parsing, owned resource identity, +//! and lifecycle plans. The executor integration supplies the real command +//! runner in a later increment. + +pub mod availability; +pub mod command; +pub mod plan; +pub mod resource; + +pub use availability::{ + check_cli_installed, 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, +}; +pub use plan::{ + CleanupPlan, EnvironmentFile, MountAccess, MountPlan, NetworkPlan, ResourceLimits, RunPlan, +}; +pub use resource::{ + ContainerName, NetworkName, OwnedResource, OwnershipLabels, OwnershipToken, ResourceKind, + ResourceName, ResourceNames, +}; diff --git a/src/backends/apple_container/common/src/plan.rs b/src/backends/apple_container/common/src/plan.rs new file mode 100644 index 000000000..a0496df6b --- /dev/null +++ b/src/backends/apple_container/common/src/plan.rs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::resource::{OwnedResource, OwnershipToken, ResourceNames}; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PlanError { + #[error("{field} must be an absolute path")] + RelativePath { field: &'static str }, + #[error("Apple Container image must not be empty")] + EmptyImage, + #[error("Apple Container CPU count must be greater than zero")] + InvalidCpuCount, + #[error("Apple Container memory limit must be greater than zero")] + InvalidMemory, +} + +/// Access granted to one host bind mount. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MountAccess { + ReadOnly, + ReadWrite, +} + +/// Canonical host-to-guest bind-mount plan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MountPlan { + pub host_path: PathBuf, + pub guest_path: PathBuf, + pub access: MountAccess, +} + +impl MountPlan { + pub fn new( + host_path: impl Into, + guest_path: impl Into, + access: MountAccess, + ) -> Result { + let host_path = host_path.into(); + let guest_path = guest_path.into(); + require_absolute(&host_path, "mount host path")?; + require_absolute(&guest_path, "mount guest path")?; + Ok(Self { + host_path, + guest_path, + access, + }) + } +} + +/// Secure environment file prepared outside process argv. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvironmentFile { + pub path: PathBuf, +} + +impl EnvironmentFile { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + require_absolute(&path, "environment file path")?; + Ok(Self { path }) + } +} + +/// Apple Container networking selected for one run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetworkPlan { + /// Apple's normal NAT-backed default network. + DefaultNat, + /// Per-run host-only network with ownership verification. + Isolated { resource: OwnedResource }, +} + +/// Optional VM resource limits. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ResourceLimits { + pub cpu_count: Option, + pub memory_mb: Option, +} + +impl ResourceLimits { + pub fn new(cpu_count: Option, memory_mb: Option) -> Result { + if cpu_count == Some(0) { + return Err(PlanError::InvalidCpuCount); + } + if memory_mb == Some(0) { + return Err(PlanError::InvalidMemory); + } + Ok(Self { + cpu_count, + memory_mb, + }) + } +} + +/// Fully typed inputs for a future one-shot Apple Container launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunPlan { + pub image: String, + pub container: OwnedResource, + pub network: NetworkPlan, + pub mounts: Vec, + pub environment_file: Option, + pub resources: ResourceLimits, +} + +impl RunPlan { + #[allow(clippy::too_many_arguments)] + pub fn new( + image: impl Into, + container_hint: &str, + token: &OwnershipToken, + isolated_network: bool, + mounts: Vec, + environment_file: Option, + resources: ResourceLimits, + ) -> Result { + let image = image.into(); + if image.trim().is_empty() { + return Err(PlanError::EmptyImage); + } + let names = ResourceNames::new(container_hint, token); + let container = OwnedResource::container(names.container, token); + let network = if isolated_network { + NetworkPlan::Isolated { + resource: OwnedResource::network(names.network, token), + } + } else { + NetworkPlan::DefaultNat + }; + Ok(Self { + image, + container, + network, + mounts, + environment_file, + resources, + }) + } + + /// Cleanup targets in safe order: container first, then its network. + pub fn cleanup_plan(&self) -> CleanupPlan { + CleanupPlan { + container: self.container.clone(), + network: match &self.network { + NetworkPlan::DefaultNat => None, + NetworkPlan::Isolated { resource } => Some(resource.clone()), + }, + } + } +} + +/// Owned resources to inspect, verify, and delete after a run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CleanupPlan { + pub container: OwnedResource, + pub network: Option, +} + +fn require_absolute(path: &Path, field: &'static str) -> Result<(), PlanError> { + if path.is_absolute() { + Ok(()) + } else { + Err(PlanError::RelativePath { field }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(target_os = "macos")] + use crate::resource::ResourceKind; + + #[cfg(target_os = "macos")] + fn token() -> OwnershipToken { + OwnershipToken::parse("0123456789abcdef0123456789abcdef").unwrap() + } + + #[test] + #[cfg(target_os = "macos")] + fn isolated_run_produces_owned_cleanup_targets() { + let plan = RunPlan::new( + "docker.io/library/alpine:3.23", + "test", + &token(), + true, + vec![MountPlan::new("/tmp/input", "/workspace/input", MountAccess::ReadOnly).unwrap()], + Some(EnvironmentFile::new("/tmp/mxc.env").unwrap()), + ResourceLimits::new(Some(2), Some(1024)).unwrap(), + ) + .unwrap(); + + let cleanup = plan.cleanup_plan(); + assert_eq!(cleanup.container.name.kind(), ResourceKind::Container); + assert_eq!( + cleanup + .network + .as_ref() + .map(|resource| resource.name.kind()), + Some(ResourceKind::Network) + ); + } + + #[test] + fn plans_reject_relative_paths_and_zero_resources() { + assert!(MountPlan::new("relative", "/guest", MountAccess::ReadOnly).is_err()); + assert!(EnvironmentFile::new("relative.env").is_err()); + assert!(ResourceLimits::new(Some(0), None).is_err()); + assert!(ResourceLimits::new(None, Some(0)).is_err()); + } +} diff --git a/src/backends/apple_container/common/src/resource.rs b/src/backends/apple_container/common/src/resource.rs new file mode 100644 index 000000000..5c078e8ff --- /dev/null +++ b/src/backends/apple_container/common/src/resource.rs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::BTreeMap; + +use thiserror::Error; + +const RESOURCE_PREFIX: &str = "mxc"; +const MAX_HINT_LENGTH: usize = 24; +const TOKEN_BYTES: usize = 16; +const TOKEN_HEX_LENGTH: usize = TOKEN_BYTES * 2; + +pub const MANAGED_LABEL: &str = "com.microsoft.mxc.managed"; +pub const OWNER_TOKEN_LABEL: &str = "com.microsoft.mxc.owner-token"; +pub const RESOURCE_KIND_LABEL: &str = "com.microsoft.mxc.resource-kind"; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ResourceError { + #[error("ownership token must contain exactly 32 lowercase hexadecimal characters")] + InvalidOwnershipToken, + #[error("the operating system random number generator is unavailable")] + RandomUnavailable, +} + +/// Unforgeable per-run ownership token used in names, labels, and recovery. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OwnershipToken(String); + +impl OwnershipToken { + /// Generate a fresh 128-bit ownership token. + pub fn generate() -> Result { + let mut bytes = [0u8; TOKEN_BYTES]; + getrandom::getrandom(&mut bytes).map_err(|_| ResourceError::RandomUnavailable)?; + Ok(Self( + bytes.iter().map(|byte| format!("{byte:02x}")).collect(), + )) + } + + /// Parse a token persisted by a prior MXC process. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.len() != TOKEN_HEX_LENGTH + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ResourceError::InvalidOwnershipToken); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Apple resource type covered by ownership verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceKind { + Container, + Network, +} + +impl ResourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Container => "container", + Self::Network => "network", + } + } +} + +/// Canonical ownership labels attached to every MXC-created Apple resource. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnershipLabels(BTreeMap); + +impl OwnershipLabels { + pub fn new(token: &OwnershipToken, kind: ResourceKind) -> Self { + Self(BTreeMap::from([ + (MANAGED_LABEL.to_string(), "true".to_string()), + (OWNER_TOKEN_LABEL.to_string(), token.as_str().to_string()), + (RESOURCE_KIND_LABEL.to_string(), kind.as_str().to_string()), + ])) + } + + pub fn iter(&self) -> impl Iterator { + self.0 + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + } + + /// Verify labels read back from Apple before a destructive operation. + pub fn matches(&self, actual: &BTreeMap) -> bool { + self.0 + .iter() + .all(|(key, expected)| actual.get(key) == Some(expected)) + } +} + +/// Collision-resistant Apple resource names for one execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceNames { + pub container: ContainerName, + pub network: NetworkName, +} + +impl ResourceNames { + pub fn new(container_hint: &str, token: &OwnershipToken) -> Self { + let hint = sanitize_hint(container_hint); + let suffix = &token.as_str()[..12]; + let stem = format!("{RESOURCE_PREFIX}-{hint}-{suffix}"); + Self { + container: ContainerName(stem.clone()), + network: NetworkName(format!("{stem}-net")), + } + } +} + +/// Name accepted only where an Apple container identity is required. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerName(String); + +impl ContainerName { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Name accepted only where an Apple network identity is required. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkName(String); + +impl NetworkName { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Typed identity for an Apple resource. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourceName { + Container(ContainerName), + Network(NetworkName), +} + +impl ResourceName { + pub fn as_str(&self) -> &str { + match self { + Self::Container(name) => name.as_str(), + Self::Network(name) => name.as_str(), + } + } + + pub fn kind(&self) -> ResourceKind { + match self { + Self::Container(_) => ResourceKind::Container, + Self::Network(_) => ResourceKind::Network, + } + } +} + +/// One named resource plus the ownership proof expected before cleanup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedResource { + pub name: ResourceName, + pub labels: OwnershipLabels, +} + +impl OwnedResource { + pub fn container(name: ContainerName, token: &OwnershipToken) -> Self { + Self { + name: ResourceName::Container(name), + labels: OwnershipLabels::new(token, ResourceKind::Container), + } + } + + pub fn network(name: NetworkName, token: &OwnershipToken) -> Self { + Self { + name: ResourceName::Network(name), + labels: OwnershipLabels::new(token, ResourceKind::Network), + } + } +} + +fn sanitize_hint(hint: &str) -> String { + let mut output = String::with_capacity(MAX_HINT_LENGTH); + let mut previous_dash = false; + for character in hint.chars() { + let normalized = character.to_ascii_lowercase(); + let accepted = normalized.is_ascii_alphanumeric(); + if accepted { + output.push(normalized); + previous_dash = false; + } else if !previous_dash && !output.is_empty() { + output.push('-'); + previous_dash = true; + } + if output.len() == MAX_HINT_LENGTH { + break; + } + } + while output.ends_with('-') { + output.pop(); + } + if output.is_empty() { + "run".to_string() + } else { + output + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token() -> OwnershipToken { + OwnershipToken::parse("0123456789abcdef0123456789abcdef").unwrap() + } + + #[test] + fn names_are_sanitized_and_share_a_collision_resistant_suffix() { + let names = ResourceNames::new(" Build Job / PR #42 ", &token()); + assert_eq!(names.container.as_str(), "mxc-build-job-pr-42-0123456789ab"); + assert_eq!( + names.network.as_str(), + "mxc-build-job-pr-42-0123456789ab-net" + ); + } + + #[test] + fn ownership_labels_require_every_expected_value() { + let expected = OwnershipLabels::new(&token(), ResourceKind::Container); + let mut actual = expected + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + assert!(expected.matches(&actual)); + + actual.insert(OWNER_TOKEN_LABEL.to_string(), "foreign".to_string()); + assert!(!expected.matches(&actual)); + } + + #[test] + fn token_parser_rejects_weak_or_noncanonical_values() { + for value in [ + "short", + "0123456789ABCDEF0123456789ABCDEF", + "0123456789abcdef0123456789abcdeg", + ] { + assert!(OwnershipToken::parse(value).is_err()); + } + } +} diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index e53e400db..9f6ba566f 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -41,6 +41,7 @@ bwrap_common = { workspace = true } lxc_common = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] +apple_container_common = { workspace = true } seatbelt_common = { workspace = true } [features]