--- a/crates/airouter-verification/src/rust.rs +++ b/crates/airouter-verification/src/rust.rs @@ -82,8 +82,82 @@ #[serde(deny_unknown_fields)] struct Config { schema: String, - cgroup_root: PathBuf, + /// Legacy single-delegation registration. Mutually exclusive with cgroup_roots. + #[serde(default)] + cgroup_root: Option, + /// Protected host registration, never a request argument or environment override. + #[serde(default)] + cgroup_roots: Option>, projects: BTreeMap, +} + +impl Config { + fn roots(&self) -> Result<&[PathBuf], String> { + let roots = match (&self.cgroup_root, &self.cgroup_roots) { + (Some(root), None) => std::slice::from_ref(root), + (None, Some(roots)) if !roots.is_empty() && roots.len() <= 16 => roots.as_slice(), + _ => return Err("register either cgroup_root or 1..16 cgroup_roots".into()), + }; + let mount = Path::new("/sys/fs/cgroup"); + for (index, root) in roots.iter().enumerate() { + if !normalized_absolute(root) || !root.starts_with(mount) || root == mount { + return Err("Rust execution requires explicit canonical cgroup delegations".into()); + } + if roots[..index] + .iter() + .any(|other| root.starts_with(other) || other.starts_with(root)) + { + return Err("Rust cgroup delegations must not overlap or repeat".into()); + } + } + Ok(roots) + } + + fn select_root(&self, membership: &str) -> Result<&Path, String> { + let roots = self.roots()?; + let mut unified = membership + .lines() + .filter_map(|line| line.strip_prefix("0::")); + let own = Path::new( + unified + .next() + .ok_or("Rust execution requires unified cgroup membership")?, + ); + if unified.next().is_some() || !normalized_absolute(own) { + return Err("invalid Rust execution cgroup membership".into()); + } + let own = Path::new("/sys/fs/cgroup").join( + own.strip_prefix("/") + .map_err(|_| "invalid Rust execution cgroup membership")?, + ); + roots + .iter() + .find(|root| own != **root && own.starts_with(root)) + .map(PathBuf::as_path) + .ok_or_else(|| "Rust execution caller is outside registered delegation leaves".into()) + } + + fn execution_root(&self) -> Result<&Path, String> { + // Validate every registered path before selection. A missing/aliased + // registration never falls back to another context or a wider ancestor. + for root in self.roots()? { + canonical_dir(root)?; + } + let membership = fs::read_to_string("/proc/self/cgroup") + .map_err(|_| "Rust execution process cgroup unavailable")?; + self.select_root(&membership) + } +} + +fn normalized_absolute(path: &Path) -> bool { + path.is_absolute() + && path.components().all(|component| { + matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + && path.components().collect::().as_os_str() == path.as_os_str() } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -186,6 +260,7 @@ if config.schema != "workbench.rust-runtime.v1" { return Err("unsupported Rust runner registration".into()); } + config.roots()?; Ok(config) } fn canonical_dir(path: &Path) -> Result<(), String> { @@ -365,9 +440,10 @@ .map_err(|_| "Rust cache is not writable")? .close() .map_err(|_| "Rust cache probe cleanup failed")?; - cgroup::delegation(&config.cgroup_root)?; + let cgroup_root = config.execution_root()?; + cgroup::delegation(cgroup_root)?; Ok( - json!({"schema":"workbench.rust-readiness.v1","status":"ready","source_sha256":snapshot.digest,"cache":cache,"lock_sha256":p.lock_sha256,"source_write_check":source_write_check,"model_calls":0}), + json!({"schema":"workbench.rust-readiness.v1","status":"ready","cgroup_root":cgroup_root,"source_sha256":snapshot.digest,"cache":cache,"lock_sha256":p.lock_sha256,"source_write_check":source_write_check,"model_calls":0}), ) } @@ -401,7 +477,8 @@ #[allow(deprecated)] flock(lock.as_raw_fd(), FlockArg::LockExclusiveNonblock) .map_err(|_| "Rust project runner busy; no build started")?; - let group = cgroup::Group::with_limits(&config.cgroup_root, LIMITS)?; + let cgroup_root = config.execution_root()?; + let group = cgroup::Group::with_limits(cgroup_root, LIMITS)?; let preparation_started = Instant::now(); let snapshot = Snapshot::read_entries(workspace, Some(&p.source_entries))?; if snapshot.file_digest("Cargo.lock").as_deref() != Some(p.lock_sha256.as_str()) { @@ -463,7 +540,7 @@ ); Ok( json!({"schema":SCHEMA,"execution":{"capability_id":"workbench/tool.rust_check","implementation_sha256":implementation}, - "project_id":project,"request":request,"succeeded":succeeded,"exit_code":exit_code,"termination":termination, + "project_id":project,"request":request,"succeeded":succeeded,"exit_code":exit_code,"termination":termination,"cgroup_root":cgroup_root, "stdout":String::from_utf8_lossy(&stdout[stdout.len().saturating_sub(8192)..]),"stderr":String::from_utf8_lossy(&stderr[stderr.len().saturating_sub(8192)..]),"output_truncated":stdout.len()>8192 || stderr.len()>8192,"duration_ms":started.elapsed().as_millis(), "source_sha256":snapshot.digest,"source_after_sha256":after.ok().map(|s|s.digest),"source_unchanged":unchanged, "cache":target,"preparation":preparation,"artifact":artifact,"artifact_error":artifact_error,"network":"isolated","workspace":"read_only_snapshot","resource_events":events.ok(),"cleanup_error":cleanup.err(), @@ -648,6 +725,119 @@ #[cfg(test)] mod tests { use super::*; + + fn registration(fields: Value) -> Config { + let mut value = json!({"schema":"workbench.rust-runtime.v1","projects":{}}); + value + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + serde_json::from_value(value).unwrap() + } + + #[test] + fn registered_contexts_select_worker_and_native_membership_independent_of_order() { + let worker = "/sys/fs/cgroup/openrc.workbench-agentd"; + let native = "/sys/fs/cgroup/openrc.airouter-sessiond.workbench"; + for roots in [vec![worker, native], vec![native, worker]] { + let config = registration(json!({"cgroup_roots":roots})); + for (membership, expected) in [ + ("0::/openrc.workbench-agentd/service\n", worker), + ("0::/openrc.airouter-sessiond.workbench/service\n", native), + ( + "0::/openrc.airouter-sessiond.workbench/exec-fixture/payload\n", + native, + ), + ] { + assert_eq!(config.select_root(membership).unwrap(), Path::new(expected)); + } + for membership in [ + "0::/", "0::/openrc.workbench-agentd", "0::/openrc.airouter-sessiond.workbench", + "0::/openrc.workbench-agentd-other/service", "0::/unregistered/service", + "0::relative/service", "0::/openrc.workbench-agentd/../unregistered/service", + "0::/openrc.workbench-agentd//service", "0::/openrc.workbench-agentd/./service", + "0::/openrc.workbench-agentd/service/", "1:cpu:/openrc.workbench-agentd/service", + "0::/openrc.workbench-agentd/service\n0::/openrc.airouter-sessiond.workbench/service", "", + ] { + assert!(config.select_root(membership).is_err(), "{membership}"); + } + } + } + + #[test] + fn legacy_single_delegation_retains_selection_and_containment() { + let config = registration(json!({"cgroup_root":"/sys/fs/cgroup/fixture"})); + assert_eq!( + config.select_root("0::/fixture/runner\n").unwrap(), + Path::new("/sys/fs/cgroup/fixture") + ); + for membership in [ + "0::/fixture", + "0::/other/runner", + "0::/fixture-other/runner", + ] { + assert!(config.select_root(membership).is_err()); + } + } + + #[test] + fn invalid_or_ambiguous_delegation_registration_fails_closed() { + for fields in [ + json!({}), + json!({"cgroup_roots":[]}), + json!({"cgroup_root":"/sys/fs/cgroup/a","cgroup_roots":["/sys/fs/cgroup/b"]}), + json!({"cgroup_roots":["/sys/fs/cgroup/a","/sys/fs/cgroup/a"]}), + json!({"cgroup_roots":["/sys/fs/cgroup/a","/sys/fs/cgroup/a/nested"]}), + json!({"cgroup_roots":["/sys/fs/cgroup/a/nested","/sys/fs/cgroup/a"]}), + json!({"cgroup_roots":(0..17).map(|i| format!("/sys/fs/cgroup/fixture-{i}")).collect::>()}), + ] { + assert!(registration(fields).roots().is_err()); + } + for root in [ + "/", + "/sys/fs/cgroup", + "/sys/fs/cgroup/", + "/sys/fs/cgroup-other/a", + "/tmp/a", + "relative", + "/sys/fs/cgroup/a/..", + "/sys/fs/cgroup/./a", + "/sys/fs/cgroup//a", + ] { + assert!( + registration(json!({"cgroup_root":root})).roots().is_err(), + "{root}" + ); + assert!( + registration(json!({"cgroup_roots":["/sys/fs/cgroup/fixture",root]})) + .roots() + .is_err(), + "{root}" + ); + } + assert!( + registration(json!({"cgroup_roots":["/sys/fs/cgroup/missing-rust-fixture"]})) + .execution_root() + .is_err() + ); + } + + #[test] + fn registration_remains_protected_and_requests_cannot_select_context() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("runtime.json"); + fs::write(&path, r#"{"schema":"workbench.rust-runtime.v1","cgroup_roots":["/sys/fs/cgroup/fixture"],"projects":{}}"#).unwrap(); + assert!(config(&path) + .err() + .unwrap() + .contains("root-owned and protected")); + for field in ["cgroup_root", "cgroup_roots", "membership"] { + let mut request = json!({"action":"test","package":"fixture"}); + request[field] = json!("/sys/fs/cgroup/fixture"); + assert!(serde_json::from_value::(request).is_err()); + } + } + #[test] fn publication_copies_cargo_hardlinks_and_rejects_aliases_and_special_files() { let cache = tempfile::tempdir().unwrap(); --- a/crates/airouter-verification/tests/rust_live.rs +++ b/crates/airouter-verification/tests/rust_live.rs @@ -1,11 +1,11 @@ //! Operator-provisioned fixture; never calls a model or touches production DB state. +use airouter_verification::rust::{preflight, preflight_writer, run, Request}; use serde_json::json; use std::{ fs, os::unix::fs::{MetadataExt, PermissionsExt}, path::PathBuf, }; -use airouter_verification::rust::{preflight, preflight_writer, run, Request}; #[tokio::test] #[ignore = "requires WB_RUST_FIXTURE_CONFIG/PROJECT/WORKSPACE and a non-root process inside its delegated cgroup leaf"] @@ -35,6 +35,8 @@ "#).unwrap(); let ready = preflight(&project, &workspace, None, &config).unwrap(); assert_eq!(ready["status"], "ready"); + let selected_root = ready["cgroup_root"].clone(); + assert!(selected_root.as_str().is_some()); let ready = preflight_writer(&project, &workspace, None, &config).unwrap(); assert_eq!(ready["source_write_check"]["status"], "ready"); let source = workspace.join("src/lib.rs"); @@ -83,6 +85,11 @@ .unwrap(); println!("first: {first}"); assert_eq!(first["succeeded"], true); + assert_eq!(first["cgroup_root"], selected_root); + assert_eq!(first["cleanup_error"], serde_json::Value::Null); + assert_eq!(first["limits"]["memory_bytes"], 4294967296u64); + assert_eq!(first["limits"]["pids"], 256); + assert_eq!(first["limits"]["cpu_cores"], 2); assert_eq!(first["source_unchanged"], true); let warm = run(&project, &workspace, request.clone(), &config) .await @@ -248,6 +255,8 @@ println!("deadline: {timed}"); assert_eq!(timed["termination"], "deadline"); assert_eq!(timed["succeeded"], false); + assert_eq!(timed["cgroup_root"], selected_root); + assert_eq!(timed["cleanup_error"], serde_json::Value::Null); let target = PathBuf::from(timed["cache"].as_str().unwrap()); assert!( target.join("started").is_file(),