diff --git a/Cargo.lock b/Cargo.lock index c30f89091..3446d6591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -127,7 +128,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -138,7 +139,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -735,6 +736,21 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -841,6 +857,12 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -857,6 +879,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -1134,6 +1162,26 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "container-device-interface" +version = "1.2.0" +source = "git+https://github.com/cncf-tags/container-device-interface-rs?rev=b8a056e92dbd159423c129cd306c93e410651b18#b8a056e92dbd159423c129cd306c93e410651b18" +dependencies = [ + "anyhow", + "const_format", + "jsonschema", + "lazy_static", + "libc", + "oci-spec 0.10.0", + "once_cell", + "path-clean", + "regex", + "semver", + "serde", + "serde_json", + "serde_yaml", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1677,6 +1725,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1728,7 +1785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1753,6 +1810,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1808,6 +1876,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -1846,6 +1925,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2145,6 +2234,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2443,7 +2534,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2860,6 +2951,42 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "jsonschema" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" @@ -3263,6 +3390,12 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "miette" version = "7.6.0" @@ -3449,6 +3582,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3475,6 +3622,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3501,6 +3663,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3533,7 +3706,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -3569,7 +3742,7 @@ dependencies = [ "http-auth", "jsonwebtoken", "lazy_static", - "oci-spec", + "oci-spec 0.9.0", "olpc-cjson", "regex", "reqwest 0.13.2", @@ -3599,6 +3772,23 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "oci-spec" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6f876ad774d6a676f7e968f5c3edacc32f90e65fe680a8b686235396556fb" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.18", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -3708,6 +3898,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "container-device-interface", "glob", "ipnet", "miette", @@ -4538,6 +4729,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + [[package]] name = "pbkdf2" version = "0.13.0" @@ -5076,7 +5273,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.38", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5114,9 +5311,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5285,6 +5482,43 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -5650,7 +5884,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5730,7 +5964,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6260,7 +6494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6707,6 +6941,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6757,7 +7002,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7407,6 +7652,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -7545,6 +7796,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -7790,7 +8051,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 150df10d6..08b4ce327 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ rustix = { version = "1.1", features = ["process"] } socket2 = "0.6" # Serialization +container-device-interface = { git = "https://github.com/cncf-tags/container-device-interface-rs", rev = "b8a056e92dbd159423c129cd306c93e410651b18" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 8b28fafa2..6afcc1c81 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -36,6 +36,9 @@ tempfile = { version = "3", optional = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +container-device-interface = { workspace = true } + [features] default = ["telemetry"] ## Compile in anonymous telemetry emission support. On by default; disable with diff --git a/crates/openshell-core/src/cdi.rs b/crates/openshell-core/src/cdi.rs new file mode 100644 index 000000000..cb1ae95c9 --- /dev/null +++ b/crates/openshell-core/src/cdi.rs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared CDI context schema and resolver helpers. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const CDI_CONTEXT_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CdiContext { + pub version: u32, + pub selected_devices: Vec, + pub spec_dirs: Vec, +} + +impl CdiContext { + #[must_use] + pub fn new(selected_devices: Vec, spec_dirs: Vec) -> Self { + Self { + version: CDI_CONTEXT_VERSION, + selected_devices, + spec_dirs, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CdiSpecDirectory { + pub path: String, + pub source: String, +} + +impl CdiSpecDirectory { + #[must_use] + pub fn new(path: impl Into, source: impl Into) -> Self { + Self { + path: path.into(), + source: source.into(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CdiDerivedRequirements { + pub device_node_paths: Vec, + pub read_only_mount_paths: Vec, + pub read_write_mount_paths: Vec, + pub additional_gids: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum CdiError { + #[error("CDI policy resolution is unavailable on this platform")] + UnsupportedPlatform, + #[error("failed to read CDI context '{}': {source}", path.display())] + ContextRead { + path: PathBuf, + source: std::io::Error, + }, + #[error("failed to parse CDI context '{}': {source}", path.display())] + ContextParse { + path: PathBuf, + source: serde_json::Error, + }, + #[error("unsupported CDI context version {0}")] + UnsupportedContextVersion(u32), + #[error("CDI spec dir '{path}' from source '{diagnostic_source}' is unsafe: {reason}")] + UnsafeSpecDir { + path: String, + diagnostic_source: String, + reason: &'static str, + }, + #[error("selected CDI device '{0}' was not found in mounted CDI specs")] + MissingDevice(String), + #[error( + "selected CDI device '{device}' was not found in mounted CDI specs after CDI spec refresh reported: {refresh_error}" + )] + MissingDeviceAfterRefresh { + device: String, + refresh_error: String, + }, + #[error("failed to merge CDI edits for '{device}': {error}")] + EditMerge { device: String, error: String }, + #[error("failed to encode resolved CDI edits: {source}")] + EditEncode { source: serde_json::Error }, + #[error("failed to decode resolved CDI edits: {source}")] + EditDecode { source: serde_json::Error }, + #[error("CDI-derived path '{path}' is unsafe: {reason}")] + UnsafePolicyPath { path: String, reason: &'static str }, + #[error("CDI path '{path}' requested conflicting access modes")] + ConflictingAccess { path: String }, + #[error( + "CDI writable mount '{path}' is not explicitly listed in the sandbox policy read_write paths" + )] + WritableMountNotAllowed { path: String }, + #[error("CDI writable mount '{path}' must target a single file, found {kind}")] + WritableMountNotFile { path: String, kind: String }, + #[error("CDI device node '{path}' must target a character or block device, found {kind}")] + DeviceNodeNotDevice { path: String, kind: String }, + #[error("CDI additionalGids must not contain root GID 0")] + RootAdditionalGid, + #[error("CDI mount '{path}' has conflicting ro/rw options")] + ConflictingMountOptions { path: String }, +} + +pub fn read_context(path: impl AsRef) -> Result { + let path = path.as_ref(); + let json = std::fs::read_to_string(path).map_err(|source| CdiError::ContextRead { + path: path.to_path_buf(), + source, + })?; + serde_json::from_str(&json).map_err(|source| CdiError::ContextParse { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(target_os = "linux")] +#[path = "cdi_linux.rs"] +mod cdi_linux; +#[cfg(target_os = "linux")] +pub use cdi_linux::resolve_cdi_context; + +#[cfg(not(target_os = "linux"))] +#[path = "cdi_stub.rs"] +mod cdi_stub; +#[cfg(not(target_os = "linux"))] +pub use cdi_stub::resolve_cdi_context; diff --git a/crates/openshell-core/src/cdi_linux.rs b/crates/openshell-core/src/cdi_linux.rs new file mode 100644 index 000000000..78f297404 --- /dev/null +++ b/crates/openshell-core/src/cdi_linux.rs @@ -0,0 +1,881 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Linux implementation of CDI policy resolution. + +use super::{CDI_CONTEXT_VERSION, CdiContext, CdiDerivedRequirements, CdiError, CdiSpecDirectory}; +use crate::paths::normalize_path; +use container_device_interface::{ + cache::{Cache, with_auto_refresh}, + container_edits::ContainerEdits as UpstreamContainerEdits, + spec_dirs::with_spec_dirs, +}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fmt; +use std::hash::BuildHasher; +use std::os::unix::fs::FileTypeExt; +use std::path::{Component, Path}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CdiPathKind { + File, + Directory, + CharacterDevice, + BlockDevice, + Other, +} + +impl fmt::Display for CdiPathKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File => f.write_str("file"), + Self::Directory => f.write_str("directory"), + Self::CharacterDevice => f.write_str("character device"), + Self::BlockDevice => f.write_str("block device"), + Self::Other => f.write_str("other"), + } + } +} + +// Temporary view over upstream-resolved CDI edits. The Rust CDI crate currently +// keeps some spec-model fields crate-private even though they are serialized and +// public in specs-go, so OpenShell serializes the merged upstream model and +// decodes only the policy-relevant fields here. +#[derive(Debug, Default, Deserialize)] +struct CdiContainerEdits { + #[serde(default, rename = "deviceNodes")] + device_nodes: Vec, + #[serde(default)] + mounts: Vec, + #[serde(default, rename = "additionalGids")] + additional_gids: Vec, +} + +#[derive(Debug, Deserialize)] +struct CdiDeviceNode { + path: String, +} + +#[derive(Debug, Deserialize)] +struct CdiMount { + #[serde(rename = "containerPath")] + container_path: String, + #[serde(default)] + options: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CdiAccess { + ReadOnly, + ReadWrite, +} + +#[derive(Debug, Default)] +struct RequirementAccumulator { + device_node_paths: BTreeSet, + mount_paths: BTreeMap, + additional_gids: BTreeSet, +} + +impl RequirementAccumulator { + fn add_device_node(&mut self, path: String, path_kind: &F) -> Result<(), CdiError> + where + F: Fn(&str) -> Option, + { + let kind = path_kind(&path); + if !matches!( + kind, + Some(CdiPathKind::CharacterDevice | CdiPathKind::BlockDevice) + ) { + return Err(CdiError::DeviceNodeNotDevice { + path, + kind: kind.map_or_else(|| "missing".to_string(), |kind| kind.to_string()), + }); + } + match self.mount_paths.get(&path).copied() { + Some(CdiAccess::ReadOnly) => Err(CdiError::ConflictingAccess { path }), + Some(CdiAccess::ReadWrite) | None => { + self.device_node_paths.insert(path); + Ok(()) + } + } + } + + fn add_mount(&mut self, path: String, access: CdiAccess) -> Result<(), CdiError> { + if access == CdiAccess::ReadOnly && self.device_node_paths.contains(&path) { + return Err(CdiError::ConflictingAccess { path }); + } + match self.mount_paths.get(&path).copied() { + Some(existing) if existing != access => Err(CdiError::ConflictingAccess { path }), + Some(_) => Ok(()), + None => { + self.mount_paths.insert(path, access); + Ok(()) + } + } + } + + fn add_gid(&mut self, gid: u32) -> Result<(), CdiError> { + if gid == 0 { + return Err(CdiError::RootAdditionalGid); + } + self.additional_gids.insert(gid); + Ok(()) + } + + fn validate_writable_mounts( + &self, + normalized_writable_file_allowlist: &HashSet, + path_kind: &F, + ) -> Result<(), CdiError> + where + F: Fn(&str) -> Option, + { + for (path, access) in &self.mount_paths { + if *access != CdiAccess::ReadWrite { + continue; + } + if !normalized_writable_file_allowlist.contains(path) { + return Err(CdiError::WritableMountNotAllowed { path: path.clone() }); + } + let kind = path_kind(path); + if kind != Some(CdiPathKind::File) { + return Err(CdiError::WritableMountNotFile { + path: path.clone(), + kind: kind.map_or_else(|| "missing".to_string(), |kind| kind.to_string()), + }); + } + } + Ok(()) + } + + fn build(self) -> CdiDerivedRequirements { + let mut read_only_mount_paths = Vec::new(); + let mut read_write_mount_paths = Vec::new(); + for (path, access) in self.mount_paths { + match access { + CdiAccess::ReadOnly => read_only_mount_paths.push(path), + CdiAccess::ReadWrite => read_write_mount_paths.push(path), + } + } + CdiDerivedRequirements { + device_node_paths: self.device_node_paths.into_iter().collect(), + read_only_mount_paths, + read_write_mount_paths, + additional_gids: self.additional_gids.into_iter().collect(), + } + } +} + +pub fn resolve_cdi_context( + context: &CdiContext, + writable_file_allowlist: &HashSet, +) -> Result { + resolve_cdi_context_with_path_kind(context, writable_file_allowlist, filesystem_path_kind) +} + +fn resolve_cdi_context_with_path_kind( + context: &CdiContext, + writable_file_allowlist: &HashSet, + path_kind: F, +) -> Result +where + F: Fn(&str) -> Option, + S: BuildHasher, +{ + validate_context(context)?; + let selected_devices = selected_cdi_devices(&context.selected_devices); + if selected_devices.is_empty() || context.spec_dirs.is_empty() { + return Ok(CdiDerivedRequirements::default()); + } + + let normalized_allowlist = writable_file_allowlist + .iter() + .map(|path| normalize_path(path)) + .collect::>(); + + let edits = resolve_container_edits(context, &selected_devices)?; + let mut accumulator = RequirementAccumulator::default(); + accumulate_requirements(&edits, &normalized_allowlist, &path_kind, &mut accumulator)?; + Ok(accumulator.build()) +} + +fn validate_context(context: &CdiContext) -> Result<(), CdiError> { + if context.version != CDI_CONTEXT_VERSION { + return Err(CdiError::UnsupportedContextVersion(context.version)); + } + for spec_dir in &context.spec_dirs { + validate_absolute_no_parent(&spec_dir.path).map_err(|reason| CdiError::UnsafeSpecDir { + path: spec_dir.path.clone(), + diagnostic_source: spec_dir.source.clone(), + reason, + })?; + } + Ok(()) +} + +fn selected_cdi_devices(device_ids: &[String]) -> Vec { + let mut seen = HashSet::new(); + let mut parsed = Vec::new(); + for raw in device_ids { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + if seen.insert(raw.to_string()) { + parsed.push(raw.to_string()); + } + } + parsed +} + +fn resolve_container_edits( + context: &CdiContext, + selected_devices: &[String], +) -> Result { + let (mut cache, refresh_error) = build_cache(&context.spec_dirs); + let mut merged = UpstreamContainerEdits::new(); + let mut applied_specs = BTreeSet::new(); + + for device_id in selected_devices { + let device = cache + .get_device(device_id) + .cloned() + .ok_or_else(|| missing_device_error(device_id, refresh_error.as_deref()))?; + let mut spec = device.get_spec(); + let spec_path = spec.get_path(); + if applied_specs.insert(spec_path) + && let Some(spec_edits) = spec.edits() + { + merged + .append(spec_edits) + .map_err(|err| CdiError::EditMerge { + device: device_id.clone(), + error: err.to_string(), + })?; + } + merged + .append(device.edits()) + .map_err(|err| CdiError::EditMerge { + device: device_id.clone(), + error: err.to_string(), + })?; + } + + let value = serde_json::to_value(&merged.container_edits) + .map_err(|source| CdiError::EditEncode { source })?; + serde_json::from_value(value).map_err(|source| CdiError::EditDecode { source }) +} + +fn missing_device_error(device: &str, refresh_error: Option<&str>) -> CdiError { + refresh_error.map_or_else( + || CdiError::MissingDevice(device.to_string()), + |refresh_error| CdiError::MissingDeviceAfterRefresh { + device: device.to_string(), + refresh_error: refresh_error.to_string(), + }, + ) +} + +fn build_cache(spec_dirs: &[CdiSpecDirectory]) -> (Cache, Option) { + let spec_dir_paths = spec_dirs + .iter() + .map(|spec_dir| spec_dir.path.as_str()) + .collect::>(); + let mut cache = Cache::default(); + cache.configure(vec![ + with_spec_dirs(&spec_dir_paths), + with_auto_refresh(false), + ]); + let refresh_error = cache.refresh().err().map(|err| { + tracing::debug!( + error = %err, + "Ignoring CDI cache refresh error; requested device lookup will determine availability" + ); + err.to_string() + }); + (cache, refresh_error) +} + +fn accumulate_requirements( + edits: &CdiContainerEdits, + normalized_writable_file_allowlist: &HashSet, + path_kind: &F, + accumulator: &mut RequirementAccumulator, +) -> Result<(), CdiError> +where + F: Fn(&str) -> Option, +{ + for device_node in &edits.device_nodes { + let path = normalize_policy_path(&device_node.path)?; + accumulator.add_device_node(path, path_kind)?; + } + + for mount in &edits.mounts { + let path = normalize_policy_path(&mount.container_path)?; + let access = mount_access(&path, &mount.options)?; + accumulator.add_mount(path, access)?; + } + + for gid in &edits.additional_gids { + accumulator.add_gid(*gid)?; + } + + accumulator.validate_writable_mounts(normalized_writable_file_allowlist, path_kind)?; + Ok(()) +} + +fn mount_access(path: &str, options: &[String]) -> Result { + let read_only_requested = options + .iter() + .any(|option| option.eq_ignore_ascii_case("ro")); + let read_write_requested = options + .iter() + .any(|option| option.eq_ignore_ascii_case("rw")); + // CDI mount options are stringly typed and runtime-specific normalization + // can differ. Treat simultaneous ro/rw as malformed instead of guessing + // which option a later mount syscall would effectively apply. + if read_only_requested && read_write_requested { + return Err(CdiError::ConflictingMountOptions { + path: path.to_string(), + }); + } + if read_write_requested { + Ok(CdiAccess::ReadWrite) + } else { + Ok(CdiAccess::ReadOnly) + } +} + +fn normalize_policy_path(path: &str) -> Result { + validate_absolute_no_parent(path).map_err(|reason| CdiError::UnsafePolicyPath { + path: path.to_string(), + reason, + })?; + let normalized = normalize_path(path); + if matches!( + normalized.as_str(), + "/" | "/dev" | "/proc" | "/sys" | "/run" | "/usr" + ) { + return Err(CdiError::UnsafePolicyPath { + path: path.to_string(), + reason: "broad root path is not allowed", + }); + } + Ok(normalized) +} + +fn validate_absolute_no_parent(path: &str) -> Result<(), &'static str> { + let path = Path::new(path); + if !path.is_absolute() { + return Err("path must be absolute"); + } + for component in path.components() { + match component { + Component::ParentDir => return Err("path must not contain '..'"), + Component::Prefix(_) => return Err("path must be a Unix-style absolute path"), + Component::CurDir => return Err("path must be normalized"), + Component::RootDir | Component::Normal(_) => {} + } + } + Ok(()) +} + +fn filesystem_path_kind(path: &str) -> Option { + let metadata = std::fs::metadata(path).ok()?; + let file_type = metadata.file_type(); + if file_type.is_char_device() { + return Some(CdiPathKind::CharacterDevice); + } + if file_type.is_block_device() { + return Some(CdiPathKind::BlockDevice); + } + if file_type.is_file() { + Some(CdiPathKind::File) + } else if file_type.is_dir() { + Some(CdiPathKind::Directory) + } else { + Some(CdiPathKind::Other) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_spec(dir: &Path, name: &str, yaml: &str) { + std::fs::write(dir.join(name), yaml).unwrap(); + } + + fn context(dir: &Path, selected_devices: &[&str]) -> CdiContext { + CdiContext::new( + selected_devices + .iter() + .map(|device| (*device).to_string()) + .collect(), + vec![CdiSpecDirectory::new( + dir.to_string_lossy().into_owned(), + "/host/cdi", + )], + ) + } + + fn resolve_with_kind( + context: &CdiContext, + writable: &[&str], + kind: impl Fn(&str) -> Option, + ) -> Result { + let writable_file_allowlist: HashSet = + writable.iter().map(|path| (*path).to_string()).collect(); + resolve_cdi_context_with_path_kind(context, &writable_file_allowlist, kind) + } + + fn always_missing(_: &str) -> Option { + None + } + + fn fake_device_node(path: &str) -> Option { + path.starts_with("/dev/") + .then_some(CdiPathKind::CharacterDevice) + } + + #[test] + fn resolves_native_single_device_requirements() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "nvidia.yaml", + r#" +cdiVersion: 0.6.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + deviceNodes: + - path: /dev/nvidiactl + - path: /dev/nvidia0 + mounts: + - hostPath: /host/libcuda.so.1 + containerPath: /usr/local/cuda/lib64/libcuda.so.1 +"#, + ); + + let requirements = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + fake_device_node, + ) + .unwrap(); + + assert_eq!( + requirements.device_node_paths, + vec!["/dev/nvidia0", "/dev/nvidiactl"] + ); + assert_eq!( + requirements.read_only_mount_paths, + vec!["/usr/local/cuda/lib64/libcuda.so.1"] + ); + } + + #[test] + fn resolves_native_all_device_requirements() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "nvidia.yaml", + r" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: all + containerEdits: + deviceNodes: + - path: /dev/nvidiactl + - path: /dev/nvidia0 + - path: /dev/nvidia1 +", + ); + + let requirements = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=all"]), + &[], + fake_device_node, + ) + .unwrap(); + + assert_eq!( + requirements.device_node_paths, + vec!["/dev/nvidia0", "/dev/nvidia1", "/dev/nvidiactl"] + ); + } + + #[test] + fn resolves_wsl_shape_requirements() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "wsl.yaml", + r" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: all + containerEdits: + deviceNodes: + - path: /dev/dxg + mounts: + - hostPath: /host/wsl/lib/libcuda.so.1 + containerPath: /usr/lib/wsl/lib/libcuda.so.1 +", + ); + + let requirements = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=all"]), + &[], + fake_device_node, + ) + .unwrap(); + + assert_eq!(requirements.device_node_paths, vec!["/dev/dxg"]); + assert_eq!( + requirements.read_only_mount_paths, + vec!["/usr/lib/wsl/lib/libcuda.so.1"] + ); + } + + #[test] + fn resolves_tegra_shape_requirements_and_gids() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "tegra.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +containerEdits: + additionalGids: [44] +devices: + - name: "0" + containerEdits: + deviceNodes: + - path: /dev/nvmap + - path: /dev/nvhost-gpu +"#, + ); + + let requirements = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + fake_device_node, + ) + .unwrap(); + + assert_eq!(requirements.additional_gids, vec![44]); + assert_eq!( + requirements.device_node_paths, + vec!["/dev/nvhost-gpu", "/dev/nvmap"] + ); + } + + #[test] + fn accepts_writable_single_file_mount_with_explicit_policy_opt_in() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "rw-file.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + mounts: + - hostPath: /host/nvidia/cache.db + containerPath: /opt/nvidia/cache.db + options: [rw] +"#, + ); + + let requirements = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &["/opt/nvidia/cache.db"], + |path| (path == "/opt/nvidia/cache.db").then_some(CdiPathKind::File), + ) + .unwrap(); + + assert_eq!( + requirements.read_write_mount_paths, + vec!["/opt/nvidia/cache.db"] + ); + } + + #[test] + fn rejects_writable_directory_mount() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "rw-dir.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + mounts: + - hostPath: /host/nvidia/cache + containerPath: /opt/nvidia/cache + options: [rw] +"#, + ); + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &["/opt/nvidia/cache"], + |path| (path == "/opt/nvidia/cache").then_some(CdiPathKind::Directory), + ) + .unwrap_err(); + + assert!(matches!(err, CdiError::WritableMountNotFile { .. })); + } + + #[test] + fn rejects_missing_selected_device() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "nvidia.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + env: + - OPEN_SHELL_TEST=1 +"#, + ); + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=1"]), + &[], + always_missing, + ) + .unwrap_err(); + + assert!(matches!(err, CdiError::MissingDevice(device) if device == "nvidia.com/gpu=1")); + } + + #[test] + fn reports_refresh_errors_when_requested_device_is_missing() { + let dir = tempfile::tempdir().unwrap(); + write_spec(dir.path(), "broken.yaml", "kind: ["); + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + fake_device_node, + ) + .unwrap_err(); + + assert!( + matches!(err, CdiError::MissingDeviceAfterRefresh { device, refresh_error } + if device == "nvidia.com/gpu=0" && !refresh_error.is_empty()) + ); + } + + #[test] + fn rejects_duplicate_cdi_device_names() { + let dir = tempfile::tempdir().unwrap(); + for (name, value) in [("first.yaml", "FIRST"), ("second.yaml", "SECOND")] { + write_spec( + dir.path(), + name, + &format!( + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + env: + - OPEN_SHELL_TEST={value} +"# + ), + ); + } + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + always_missing, + ) + .unwrap_err(); + + assert!( + matches!(err, CdiError::MissingDeviceAfterRefresh { device, refresh_error } + if device == "nvidia.com/gpu=0" + && refresh_error.contains("conflicting device nvidia.com/gpu=0")) + ); + } + + #[test] + fn empty_selection_is_noop() { + let dir = tempfile::tempdir().unwrap(); + let requirements = resolve_with_kind(&context(dir.path(), &[]), &[], always_missing) + .expect("empty selection should resolve to empty requirements"); + + assert_eq!(requirements, CdiDerivedRequirements::default()); + } + + #[test] + fn empty_spec_dirs_are_noop() { + let context = CdiContext::new(vec!["nvidia.com/gpu=0".to_string()], Vec::new()); + + let requirements = resolve_with_kind(&context, &[], always_missing) + .expect("empty spec dirs should resolve to empty requirements"); + + assert_eq!(requirements, CdiDerivedRequirements::default()); + } + + #[test] + fn defers_device_id_shape_to_upstream_resolution() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "nvidia.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + env: + - OPEN_SHELL_TEST=1 +"#, + ); + + let err = resolve_with_kind(&context(dir.path(), &["not-a-cdi-id"]), &[], always_missing) + .unwrap_err(); + + assert!(matches!(err, CdiError::MissingDevice(device) if device == "not-a-cdi-id")); + } + + #[test] + fn rejects_unsafe_policy_paths() { + for path in [ + "relative", "/dev", "/proc", "/sys", "/run", "/usr", "/a/../b", + ] { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "unsafe.yaml", + &format!( + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + deviceNodes: + - path: {path} +"# + ), + ); + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + always_missing, + ) + .unwrap_err(); + + assert!( + matches!(err, CdiError::UnsafePolicyPath { .. }), + "expected unsafe path error for {path}, got {err:?}" + ); + } + } + + #[test] + fn rejects_duplicate_access_conflicts() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "conflict.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + deviceNodes: + - path: /dev/nvidia0 + mounts: + - hostPath: /host/dev/nvidia0 + containerPath: /dev/nvidia0 + options: [ro] +"#, + ); + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + fake_device_node, + ) + .unwrap_err(); + + assert!(matches!(err, CdiError::ConflictingAccess { .. })); + } + + #[test] + fn rejects_root_additional_gid() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "root-gid.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + additionalGids: [0, 44] +"#, + ); + + let err = resolve_with_kind( + &context(dir.path(), &["nvidia.com/gpu=0"]), + &[], + always_missing, + ) + .unwrap_err(); + + assert!(matches!(err, CdiError::RootAdditionalGid)); + } + + #[test] + fn rejects_device_node_that_is_not_device() { + let dir = tempfile::tempdir().unwrap(); + write_spec( + dir.path(), + "regular-file-node.yaml", + r#" +cdiVersion: 1.1.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + deviceNodes: + - path: /opt/nvidia/not-a-device +"#, + ); + + let err = resolve_with_kind(&context(dir.path(), &["nvidia.com/gpu=0"]), &[], |path| { + (path == "/opt/nvidia/not-a-device").then_some(CdiPathKind::File) + }) + .unwrap_err(); + + assert!(matches!(err, CdiError::DeviceNodeNotDevice { .. })); + } +} diff --git a/crates/openshell-core/src/cdi_stub.rs b/crates/openshell-core/src/cdi_stub.rs new file mode 100644 index 000000000..552e5ad8a --- /dev/null +++ b/crates/openshell-core/src/cdi_stub.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Fail-closed CDI policy resolution for platforms other than Linux. + +use std::collections::HashSet; +use std::hash::BuildHasher; + +use super::{CdiContext, CdiDerivedRequirements, CdiError}; + +pub fn resolve_cdi_context( + _context: &CdiContext, + _writable_file_allowlist: &HashSet, +) -> Result { + Err(CdiError::UnsupportedPlatform) +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index d373d656e..ff3a550d7 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod activity; pub mod auth; +pub mod cdi; pub mod config; pub mod container_paths; pub mod denial; diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 1549258fa..fe13efc18 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -85,6 +85,14 @@ pub const SANDBOX_TOKEN_FILE: &str = "OPENSHELL_SANDBOX_TOKEN_FILE"; /// SSH child processes (which use `env_clear()` for security isolation). pub const USER_ENVIRONMENT: &str = "OPENSHELL_USER_ENVIRONMENT"; +/// Path to the supervisor-only CDI context JSON file. +/// +/// Set by CDI-capable compute drivers only when a sandbox requested GPU/CDI +/// devices. The sandbox supervisor reads this file before policy construction +/// and derives inner filesystem and process requirements from mounted CDI +/// specs. +pub const CDI_CONTEXT: &str = "OPENSHELL_CDI_CONTEXT"; + /// Path to the projected `ServiceAccount` JWT (Kubernetes driver). /// /// Used to bootstrap a gateway-minted JWT via `IssueSandboxToken`. Kubelet diff --git a/deny.toml b/deny.toml index c4dab1233..19053fa79 100644 --- a/deny.toml +++ b/deny.toml @@ -67,4 +67,4 @@ external-default-features = "allow" unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +allow-git = ["https://github.com/cncf-tags/container-device-interface-rs"]