1use std::ffi::CString;
24use std::os::unix::ffi::OsStrExt;
25use std::path::PathBuf;
26
27use super::{PROCESS_COUNT_EXIT, SANDBOX_SETUP_EXIT, WORKER_BINARY};
28
29#[derive(Debug)]
33struct Refusal {
34 detail: String,
35 exit: i32,
36}
37
38impl From<String> for Refusal {
39 fn from(detail: String) -> Refusal {
40 Refusal {
41 detail,
42 exit: SANDBOX_SETUP_EXIT,
43 }
44 }
45}
46
47pub struct HelperConfig {
49 pub jail: PathBuf,
51 pub worker: PathBuf,
53 pub adapter: String,
55 pub address_space_bytes: Option<u64>,
58 pub cpu_seconds: u64,
60 pub file_size_bytes: u64,
62 pub max_processes: u64,
64 pub seccomp: bool,
66 pub exec_grants: Vec<PathBuf>,
69 pub read_grants: Vec<PathBuf>,
72}
73
74pub fn parse_args(args: &[String]) -> Result<HelperConfig, String> {
76 let mut jail = None;
77 let mut worker = None;
78 let mut adapter = None;
79 let mut address_space_bytes: Option<Option<u64>> = None;
80 let mut cpu_seconds = None;
81 let mut file_size_bytes = None;
82 let mut max_processes = None;
83 let mut seccomp = None;
84 let mut exec_grants = Vec::new();
85 let mut read_grants = Vec::new();
86 let mut iter = args.iter();
87 while let Some(flag) = iter.next() {
88 let value = iter
89 .next()
90 .ok_or_else(|| "a helper flag is missing its value".to_string())?;
91 match flag.as_str() {
92 "--jail" => jail = Some(PathBuf::from(value)),
93 "--worker" => worker = Some(PathBuf::from(value)),
94 "--adapter" => adapter = Some(value.clone()),
95 "--rlimit-as" => {
96 address_space_bytes = Some(if value == "none" {
97 None
98 } else {
99 Some(parse_number(flag, value)?)
100 })
101 }
102 "--rlimit-cpu" => cpu_seconds = Some(parse_number(flag, value)?),
103 "--rlimit-fsize" => file_size_bytes = Some(parse_number(flag, value)?),
104 "--rlimit-nproc" => max_processes = Some(parse_number(flag, value)?),
105 "--seccomp" => {
106 seccomp = Some(match value.as_str() {
107 "kill" => true,
108 "off" => false,
109 _ => return Err("unknown seccomp mode".to_string()),
110 })
111 }
112 "--grant-exec" => exec_grants.push(PathBuf::from(value)),
113 "--grant-read" => read_grants.push(PathBuf::from(value)),
114 _ => return Err("unknown helper flag".to_string()),
115 }
116 }
117 Ok(HelperConfig {
118 jail: jail.ok_or("missing --jail")?,
119 worker: worker.ok_or("missing --worker")?,
120 adapter: adapter.ok_or("missing --adapter")?,
121 address_space_bytes: address_space_bytes.ok_or("missing --rlimit-as")?,
122 cpu_seconds: cpu_seconds.ok_or("missing --rlimit-cpu")?,
123 file_size_bytes: file_size_bytes.ok_or("missing --rlimit-fsize")?,
124 max_processes: max_processes.ok_or("missing --rlimit-nproc")?,
125 seccomp: seccomp.ok_or("missing --seccomp")?,
126 exec_grants,
127 read_grants,
128 })
129}
130
131fn parse_number(flag: &str, value: &str) -> Result<u64, String> {
132 value
133 .parse()
134 .map_err(|_| format!("flag {flag} does not carry a number"))
135}
136
137pub fn helper_main(args: &[String]) -> ! {
140 let config = match parse_args(args) {
141 Ok(config) => config,
142 Err(detail) => die(&detail, SANDBOX_SETUP_EXIT),
143 };
144 if let Err(refusal) = engage(&config) {
145 die(&refusal.detail, refusal.exit);
146 }
147 let error = exec_adapter(&config);
149 die(&error, SANDBOX_SETUP_EXIT)
150}
151
152fn die(detail: &str, exit: i32) -> ! {
153 eprintln!("sandbox_setup_failed: {detail}");
154 std::process::exit(exit)
155}
156
157#[cfg(target_os = "linux")]
158fn engage(config: &HelperConfig) -> Result<(), Refusal> {
159 super::linux::unshare_namespaces()?;
160 apply_rlimits(config)?;
161 close_extra_fds()?;
162 super::linux::apply_landlock(
163 &config.jail,
164 &config.worker,
165 &config.exec_grants,
166 &config.read_grants,
167 )?;
168 if config.seccomp {
169 super::linux::apply_seccomp()?;
170 }
171 Ok(())
172}
173
174#[cfg(target_os = "macos")]
175fn engage(config: &HelperConfig) -> Result<(), Refusal> {
176 apply_rlimits(config)?;
178 close_extra_fds()?;
179 Ok(())
180}
181
182#[cfg(not(any(target_os = "linux", target_os = "macos")))]
183fn engage(_config: &HelperConfig) -> Result<(), Refusal> {
184 Err("no jail backend exists for this platform"
185 .to_string()
186 .into())
187}
188
189fn apply_rlimits(config: &HelperConfig) -> Result<(), Refusal> {
204 use rlimit::{Resource, setrlimit};
205 let apply = |resource: Resource, name: &str, value: u64| {
206 setrlimit(resource, value, value).map_err(|_| format!("setrlimit {name} failed"))
207 };
208 #[cfg(target_os = "linux")]
209 if let Some(bytes) = config.address_space_bytes {
210 apply(Resource::AS, "RLIMIT_AS", bytes)?;
211 }
212 apply(Resource::CPU, "RLIMIT_CPU", config.cpu_seconds)?;
213 apply(Resource::FSIZE, "RLIMIT_FSIZE", config.file_size_bytes)?;
214 apply(
215 Resource::NPROC,
216 "RLIMIT_NPROC",
217 nproc_limit(config.max_processes)?,
218 )?;
219 apply(Resource::CORE, "RLIMIT_CORE", 0)?;
220 Ok(())
221}
222
223fn nproc_limit(configured: u64) -> Result<u64, Refusal> {
247 use rlimit::{Resource, getrlimit};
248 let (_, hard) =
249 getrlimit(Resource::NPROC).map_err(|_| "getrlimit RLIMIT_NPROC failed".to_string())?;
250 headroom_limit(configured, hard, current_uid_process_count)
251}
252
253fn headroom_limit(
256 configured: u64,
257 hard: u64,
258 count: impl Fn() -> std::io::Result<u64>,
259) -> Result<u64, Refusal> {
260 let base = count().map_err(|e| Refusal {
261 detail: format!(
262 "the real user id's process count could not be measured, so the headroom this limit rides on is unknown: {:?}",
263 e.kind()
264 ),
265 exit: PROCESS_COUNT_EXIT,
266 })?;
267 Ok(base.saturating_add(configured).min(hard))
268}
269
270#[cfg(target_os = "macos")]
275fn current_uid_process_count() -> std::io::Result<u64> {
276 use libproc::processes::{ProcFilter, pids_by_type};
277 let uid = nix::unistd::getuid().as_raw();
278 let pids = pids_by_type(ProcFilter::ByUID { uid }).map_err(std::io::Error::other)?;
279 Ok(pids.len() as u64)
280}
281
282#[cfg(target_os = "linux")]
287fn current_uid_process_count() -> std::io::Result<u64> {
288 use std::os::unix::fs::MetadataExt;
289 let uid = u32::from(nix::unistd::getuid().as_raw());
290 let mut count: u64 = 0;
291 for entry in std::fs::read_dir("/proc")? {
292 let entry = entry?;
293 let name = entry.file_name();
294 if name.to_str().is_none_or(|n| n.parse::<u32>().is_err()) {
295 continue;
296 }
297 match entry.metadata() {
298 Ok(metadata) => {
299 if metadata.uid() == uid {
300 count += 1;
301 }
302 }
303 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
304 Err(e) => return Err(e),
305 }
306 }
307 Ok(count)
308}
309
310#[cfg(not(any(target_os = "macos", target_os = "linux")))]
311fn current_uid_process_count() -> std::io::Result<u64> {
312 Err(std::io::Error::new(
313 std::io::ErrorKind::Unsupported,
314 "no process listing exists for this platform",
315 ))
316}
317
318fn close_extra_fds() -> Result<(), String> {
326 #[cfg(target_os = "linux")]
327 const FD_DIR: &str = "/proc/self/fd";
328 #[cfg(not(target_os = "linux"))]
329 const FD_DIR: &str = "/dev/fd";
330 let entries = std::fs::read_dir(FD_DIR)
331 .map_err(|e| format!("cannot enumerate descriptors: {:?}", e.kind()))?;
332 let mut fds = Vec::new();
333 for entry in entries {
334 let entry = entry.map_err(|e| format!("cannot enumerate descriptors: {:?}", e.kind()))?;
335 if let Some(fd) = entry
336 .file_name()
337 .to_str()
338 .and_then(|name| name.parse::<i32>().ok())
339 && fd > 2
340 {
341 fds.push(fd);
342 }
343 }
344 for fd in fds {
345 let _ = nix::unistd::close(fd);
347 }
348 Ok(())
349}
350
351fn exec_adapter(config: &HelperConfig) -> String {
354 let path = match CString::new(config.worker.as_os_str().as_bytes()) {
355 Ok(path) => path,
356 Err(_) => return "worker path contains a NUL byte".to_string(),
357 };
358 let argv0 = CString::new(WORKER_BINARY).expect("static name has no NUL");
359 let mode = match CString::new(config.adapter.as_str()) {
360 Ok(mode) => mode,
361 Err(_) => return "adapter mode contains a NUL byte".to_string(),
362 };
363 match nix::unistd::execv(&path, &[argv0.as_c_str(), mode.as_c_str()]) {
364 Ok(infallible) => match infallible {},
365 Err(e) => format!("exec of the adapter mode failed: {e:?}"),
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn the_process_limit_rides_on_the_measured_count_and_clamps() {
375 let limit = headroom_limit(64, 4096, || Ok(609)).expect("a measured count computes");
377 assert_eq!(limit, 673);
378 let clamped = headroom_limit(64, 650, || Ok(609)).expect("a measured count computes");
380 assert_eq!(clamped, 650);
381 let idle = headroom_limit(64, 4096, || Ok(0)).expect("a measured count computes");
383 assert_eq!(idle, 64);
384 }
385
386 #[test]
387 fn a_count_the_host_cannot_supply_refuses_the_run() {
388 let refusal = headroom_limit(64, 4096, || {
392 Err(std::io::Error::other("the listing is unavailable"))
393 })
394 .expect_err("an unmeasurable count must refuse the run");
395 assert_eq!(refusal.exit, PROCESS_COUNT_EXIT);
396 assert_ne!(refusal.exit, SANDBOX_SETUP_EXIT);
397 assert!(refusal.detail.contains("Other"), "{}", refusal.detail);
398 }
399
400 #[cfg(any(target_os = "macos", target_os = "linux"))]
401 #[test]
402 fn this_host_can_be_counted() {
403 let count = current_uid_process_count().expect("the host answers its own process listing");
406 assert!(count > 0, "the count was {count}");
407 }
408}