Skip to content

Commit 61d751d

Browse files
committed
fixup! feat(gpu): derive sandbox requirements from CDI specs
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 9862fcd commit 61d751d

6 files changed

Lines changed: 428 additions & 279 deletions

File tree

crates/openshell-core/src/cdi.rs

Lines changed: 118 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ impl CdiSpecDirectory {
7070
pub struct CdiDerivedRequirements {
7171
pub read_only_paths: Vec<String>,
7272
pub read_write_paths: Vec<String>,
73-
pub supplemental_gids: Vec<u32>,
73+
pub additional_gids: Vec<u32>,
7474
}
7575

7676
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -104,22 +104,12 @@ pub enum CdiError {
104104
},
105105
#[error("unsupported CDI context version {0}")]
106106
UnsupportedContextVersion(u32),
107-
#[error("CDI context selected_devices must not be empty")]
108-
EmptySelection,
109-
#[error("CDI context spec_dirs must not be empty")]
110-
EmptySpecDirs,
111-
#[error("invalid CDI selected device ID '{0}'; expected '<kind>=<name>'")]
112-
InvalidSelectedDevice(String),
113107
#[error("CDI spec dir '{path}' from source '{diagnostic_source}' is unsafe: {reason}")]
114108
UnsafeSpecDir {
115109
path: String,
116110
diagnostic_source: String,
117111
reason: &'static str,
118112
},
119-
#[error("no CDI spec files were found in the mounted spec dirs")]
120-
NoSpecFiles,
121-
#[error("failed to refresh CDI cache from mounted spec dirs: {error}")]
122-
CacheRefresh { error: String },
123113
#[error("selected CDI device '{0}' was not found in mounted CDI specs")]
124114
MissingDevice(String),
125115
#[error("failed to merge CDI edits for '{device}': {error}")]
@@ -140,8 +130,6 @@ pub enum CdiError {
140130
WritableMountNotFile { path: String, kind: String },
141131
#[error("CDI mount '{path}' has conflicting ro/rw options")]
142132
ConflictingMountOptions { path: String },
143-
#[error("CDI additionalGids must not include root GID 0")]
144-
RootAdditionalGid,
145133
}
146134

147135
// Temporary view over upstream-resolved CDI edits. The Rust CDI crate currently
@@ -180,10 +168,22 @@ enum CdiAccess {
180168
#[derive(Debug, Default)]
181169
struct RequirementAccumulator {
182170
paths: BTreeMap<String, CdiAccess>,
183-
gids: BTreeSet<u32>,
171+
writable_mount_paths: BTreeSet<String>,
172+
additional_gids: BTreeSet<u32>,
184173
}
185174

186175
impl RequirementAccumulator {
176+
fn add_device_node(&mut self, path: String) -> Result<(), CdiError> {
177+
self.add_path(path, CdiAccess::ReadWrite)
178+
}
179+
180+
fn add_mount(&mut self, path: String, access: CdiAccess) -> Result<(), CdiError> {
181+
if access == CdiAccess::ReadWrite {
182+
self.writable_mount_paths.insert(path.clone());
183+
}
184+
self.add_path(path, access)
185+
}
186+
187187
fn add_path(&mut self, path: String, access: CdiAccess) -> Result<(), CdiError> {
188188
match self.paths.get(&path).copied() {
189189
Some(existing) if existing != access => Err(CdiError::ConflictingAccess { path }),
@@ -195,11 +195,34 @@ impl RequirementAccumulator {
195195
}
196196
}
197197

198-
fn add_gid(&mut self, gid: u32) -> Result<(), CdiError> {
198+
fn add_gid(&mut self, gid: u32) {
199199
if gid == 0 {
200-
return Err(CdiError::RootAdditionalGid);
200+
tracing::debug!("Skipping CDI additionalGids entry for root GID 0");
201+
return;
202+
}
203+
self.additional_gids.insert(gid);
204+
}
205+
206+
fn validate_writable_mounts<F>(
207+
&self,
208+
normalized_writable_file_allowlist: &HashSet<String>,
209+
path_kind: &F,
210+
) -> Result<(), CdiError>
211+
where
212+
F: Fn(&str) -> Option<CdiPathKind>,
213+
{
214+
for path in &self.writable_mount_paths {
215+
if !normalized_writable_file_allowlist.contains(path) {
216+
return Err(CdiError::WritableMountNotAllowed { path: path.clone() });
217+
}
218+
let kind = path_kind(path);
219+
if kind != Some(CdiPathKind::File) {
220+
return Err(CdiError::WritableMountNotFile {
221+
path: path.clone(),
222+
kind: kind.map_or_else(|| "missing".to_string(), |kind| kind.to_string()),
223+
});
224+
}
201225
}
202-
self.gids.insert(gid);
203226
Ok(())
204227
}
205228

@@ -211,7 +234,7 @@ impl RequirementAccumulator {
211234
CdiAccess::ReadWrite => requirements.read_write_paths.push(path),
212235
}
213236
}
214-
requirements.supplemental_gids = self.gids.into_iter().collect();
237+
requirements.additional_gids = self.additional_gids.into_iter().collect();
215238
requirements
216239
}
217240
}
@@ -245,28 +268,26 @@ where
245268
S: BuildHasher,
246269
{
247270
validate_context(context)?;
248-
let selected_devices = parse_selected_devices(&context.selected_devices)?;
271+
let selected_devices = selected_cdi_devices(&context.selected_devices);
272+
if selected_devices.is_empty() || context.spec_dirs.is_empty() {
273+
return Ok(CdiDerivedRequirements::default());
274+
}
275+
249276
let normalized_allowlist = writable_file_allowlist
250277
.iter()
251278
.map(|path| normalize_path(path))
252279
.collect::<HashSet<_>>();
253280

254281
let edits = resolve_container_edits(context, &selected_devices)?;
255282
let mut accumulator = RequirementAccumulator::default();
256-
apply_edits(&edits, &normalized_allowlist, &path_kind, &mut accumulator)?;
283+
accumulate_requirements(&edits, &normalized_allowlist, &path_kind, &mut accumulator)?;
257284
Ok(accumulator.build())
258285
}
259286

260287
fn validate_context(context: &CdiContext) -> Result<(), CdiError> {
261288
if context.version != CDI_CONTEXT_VERSION {
262289
return Err(CdiError::UnsupportedContextVersion(context.version));
263290
}
264-
if context.selected_devices.is_empty() {
265-
return Err(CdiError::EmptySelection);
266-
}
267-
if context.spec_dirs.is_empty() {
268-
return Err(CdiError::EmptySpecDirs);
269-
}
270291
for spec_dir in &context.spec_dirs {
271292
validate_absolute_no_parent(&spec_dir.path).map_err(|reason| CdiError::UnsafeSpecDir {
272293
path: spec_dir.path.clone(),
@@ -277,29 +298,26 @@ fn validate_context(context: &CdiContext) -> Result<(), CdiError> {
277298
Ok(())
278299
}
279300

280-
fn parse_selected_devices(device_ids: &[String]) -> Result<Vec<String>, CdiError> {
301+
fn selected_cdi_devices(device_ids: &[String]) -> Vec<String> {
281302
let mut seen = HashSet::new();
282303
let mut parsed = Vec::new();
283304
for raw in device_ids {
284305
let raw = raw.trim();
285-
let Some((kind, name)) = raw.rsplit_once('=') else {
286-
return Err(CdiError::InvalidSelectedDevice(raw.to_string()));
287-
};
288-
if kind.trim().is_empty() || name.trim().is_empty() {
289-
return Err(CdiError::InvalidSelectedDevice(raw.to_string()));
306+
if raw.is_empty() {
307+
continue;
290308
}
291309
if seen.insert(raw.to_string()) {
292310
parsed.push(raw.to_string());
293311
}
294312
}
295-
Ok(parsed)
313+
parsed
296314
}
297315

298316
fn resolve_container_edits(
299317
context: &CdiContext,
300318
selected_devices: &[String],
301319
) -> Result<CdiContainerEdits, CdiError> {
302-
let mut cache = build_cache(&context.spec_dirs)?;
320+
let mut cache = build_cache(&context.spec_dirs);
303321
let mut merged = UpstreamContainerEdits::new();
304322
let mut applied_specs = BTreeSet::new();
305323

@@ -333,7 +351,7 @@ fn resolve_container_edits(
333351
serde_json::from_value(value).map_err(|source| CdiError::EditDecode { source })
334352
}
335353

336-
fn build_cache(spec_dirs: &[CdiSpecDirectory]) -> Result<Cache, CdiError> {
354+
fn build_cache(spec_dirs: &[CdiSpecDirectory]) -> Cache {
337355
let spec_dir_paths = spec_dirs
338356
.iter()
339357
.map(|spec_dir| spec_dir.path.as_str())
@@ -343,16 +361,16 @@ fn build_cache(spec_dirs: &[CdiSpecDirectory]) -> Result<Cache, CdiError> {
343361
with_spec_dirs(&spec_dir_paths),
344362
with_auto_refresh(false),
345363
]);
346-
cache.refresh().map_err(|err| CdiError::CacheRefresh {
347-
error: err.to_string(),
348-
})?;
349-
if cache.list_devices().is_empty() {
350-
return Err(CdiError::NoSpecFiles);
364+
if let Err(err) = cache.refresh() {
365+
tracing::debug!(
366+
error = %err,
367+
"Ignoring CDI cache refresh error; requested device lookup will determine availability"
368+
);
351369
}
352-
Ok(cache)
370+
cache
353371
}
354372

355-
fn apply_edits<F>(
373+
fn accumulate_requirements<F>(
356374
edits: &CdiContainerEdits,
357375
normalized_writable_file_allowlist: &HashSet<String>,
358376
path_kind: &F,
@@ -363,33 +381,20 @@ where
363381
{
364382
for device_node in &edits.device_nodes {
365383
let path = normalize_policy_path(&device_node.path)?;
366-
accumulator.add_path(path, CdiAccess::ReadWrite)?;
384+
accumulator.add_device_node(path)?;
367385
}
368386

369387
for mount in &edits.mounts {
370388
let path = normalize_policy_path(&mount.container_path)?;
371-
match mount_access(&path, &mount.options)? {
372-
CdiAccess::ReadOnly => accumulator.add_path(path, CdiAccess::ReadOnly)?,
373-
CdiAccess::ReadWrite => {
374-
if !normalized_writable_file_allowlist.contains(&path) {
375-
return Err(CdiError::WritableMountNotAllowed { path });
376-
}
377-
let kind = path_kind(&path);
378-
if kind != Some(CdiPathKind::File) {
379-
return Err(CdiError::WritableMountNotFile {
380-
path,
381-
kind: kind.map_or_else(|| "missing".to_string(), |kind| kind.to_string()),
382-
});
383-
}
384-
accumulator.add_path(path, CdiAccess::ReadWrite)?;
385-
}
386-
}
389+
let access = mount_access(&path, &mount.options)?;
390+
accumulator.add_mount(path, access)?;
387391
}
388392

389393
for gid in &edits.additional_gids {
390-
accumulator.add_gid(*gid)?;
394+
accumulator.add_gid(*gid);
391395
}
392396

397+
accumulator.validate_writable_mounts(normalized_writable_file_allowlist, path_kind)?;
393398
Ok(())
394399
}
395400

@@ -400,6 +405,9 @@ fn mount_access(path: &str, options: &[String]) -> Result<CdiAccess, CdiError> {
400405
let read_write_requested = options
401406
.iter()
402407
.any(|option| option.eq_ignore_ascii_case("rw"));
408+
// CDI mount options are stringly typed and runtime-specific normalization
409+
// can differ. Treat simultaneous ro/rw as malformed instead of guessing
410+
// which option a later mount syscall would effectively apply.
403411
if read_only_requested && read_write_requested {
404412
return Err(CdiError::ConflictingMountOptions {
405413
path: path.to_string(),
@@ -624,7 +632,7 @@ devices:
624632
)
625633
.unwrap();
626634

627-
assert_eq!(requirements.supplemental_gids, vec![44]);
635+
assert_eq!(requirements.additional_gids, vec![44]);
628636
assert_eq!(
629637
requirements.read_write_paths,
630638
vec!["/dev/nvhost-gpu", "/dev/nvmap"]
@@ -717,7 +725,7 @@ devices:
717725
}
718726

719727
#[test]
720-
fn rejects_malformed_spec() {
728+
fn defers_malformed_spec_errors_to_device_resolution() {
721729
let dir = tempfile::tempdir().unwrap();
722730
write_spec(dir.path(), "broken.yaml", "kind: [");
723731

@@ -728,7 +736,49 @@ devices:
728736
)
729737
.unwrap_err();
730738

731-
assert!(matches!(err, CdiError::CacheRefresh { .. }));
739+
assert!(matches!(err, CdiError::MissingDevice(device) if device == "nvidia.com/gpu=0"));
740+
}
741+
742+
#[test]
743+
fn empty_selection_is_noop() {
744+
let dir = tempfile::tempdir().unwrap();
745+
let requirements = resolve_with_kind(&context(dir.path(), &[]), &[], always_missing)
746+
.expect("empty selection should resolve to empty requirements");
747+
748+
assert_eq!(requirements, CdiDerivedRequirements::default());
749+
}
750+
751+
#[test]
752+
fn empty_spec_dirs_are_noop() {
753+
let context = CdiContext::new("test", vec!["nvidia.com/gpu=0".to_string()], Vec::new());
754+
755+
let requirements = resolve_with_kind(&context, &[], always_missing)
756+
.expect("empty spec dirs should resolve to empty requirements");
757+
758+
assert_eq!(requirements, CdiDerivedRequirements::default());
759+
}
760+
761+
#[test]
762+
fn defers_device_id_shape_to_upstream_resolution() {
763+
let dir = tempfile::tempdir().unwrap();
764+
write_spec(
765+
dir.path(),
766+
"nvidia.yaml",
767+
r#"
768+
cdiVersion: 1.1.0
769+
kind: nvidia.com/gpu
770+
devices:
771+
- name: "0"
772+
containerEdits:
773+
env:
774+
- OPEN_SHELL_TEST=1
775+
"#,
776+
);
777+
778+
let err = resolve_with_kind(&context(dir.path(), &["not-a-cdi-id"]), &[], always_missing)
779+
.unwrap_err();
780+
781+
assert!(matches!(err, CdiError::MissingDevice(device) if device == "not-a-cdi-id"));
732782
}
733783

734784
#[test]
@@ -799,7 +849,7 @@ devices:
799849
}
800850

801851
#[test]
802-
fn rejects_root_additional_gid() {
852+
fn skips_root_additional_gid() {
803853
let dir = tempfile::tempdir().unwrap();
804854
write_spec(
805855
dir.path(),
@@ -810,17 +860,17 @@ kind: nvidia.com/gpu
810860
devices:
811861
- name: "0"
812862
containerEdits:
813-
additionalGids: [0]
863+
additionalGids: [0, 44]
814864
"#,
815865
);
816866

817-
let err = resolve_with_kind(
867+
let requirements = resolve_with_kind(
818868
&context(dir.path(), &["nvidia.com/gpu=0"]),
819869
&[],
820870
always_missing,
821871
)
822-
.unwrap_err();
872+
.unwrap();
823873

824-
assert!(matches!(err, CdiError::RootAdditionalGid));
874+
assert_eq!(requirements.additional_gids, vec![44]);
825875
}
826876
}

crates/openshell-core/src/policy.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ pub struct ProcessPolicy {
8484
/// Group name to run the sandboxed process as.
8585
pub run_as_group: Option<String>,
8686

87-
/// Runtime-derived supplemental GIDs to apply before dropping privileges.
87+
/// Linux supplemental groups to apply before dropping privileges.
88+
///
89+
/// Runtime-specific inputs can use different terminology; CDI
90+
/// `additionalGids` are converted into this process-level representation.
8891
pub supplemental_groups: Vec<u32>,
8992
}
9093

0 commit comments

Comments
 (0)