1use std::{
17 path::{Path, PathBuf},
18 sync::{
19 atomic::{AtomicI32, Ordering},
20 mpsc, Arc,
21 },
22 time::{Duration, Instant},
23};
24
25use serde::{Deserialize, Serialize};
26
27use crate::{
28 sandbox::{HostProbe, ShellEnforcement},
29 shell::{ShellExecError, ShellResult},
30 types::{CapabilityPolicy, StagedSession},
31 workdir_fs::WorkdirRoot,
32};
33
34pub const CHECK_OUTPUT_TAIL_BYTES: usize = 4096;
37
38pub const CHECK_LOG_FILE: &str = "logs/check.log";
41
42const KILLED_OUTPUT_WAIT: Duration = Duration::from_secs(10);
45
46#[derive(Debug, Clone)]
48pub struct SessionCheck {
49 command: String,
50 binary: String,
51 args: Vec<String>,
52 files: Option<PathBuf>,
53 timeout: Duration,
54}
55
56impl SessionCheck {
57 pub fn new(command: &str, files: Option<PathBuf>, timeout: Duration) -> Result<Self, String> {
62 let mut words = crate::shell::split_shell_words(command);
63 if words.is_empty() {
64 return Err("the check command is empty".to_string());
65 }
66 if timeout.is_zero() {
67 return Err("the check timeout is zero".to_string());
68 }
69 let binary = words.remove(0);
70 Ok(Self {
71 command: command.to_string(),
72 binary,
73 args: words,
74 files,
75 timeout,
76 })
77 }
78
79 #[must_use]
81 pub fn binary(&self) -> &str {
82 &self.binary
83 }
84
85 #[must_use]
87 pub fn args(&self) -> &[String] {
88 &self.args
89 }
90
91 #[must_use]
93 pub fn command(&self) -> &str {
94 &self.command
95 }
96
97 #[must_use]
99 pub fn files(&self) -> Option<&Path> {
100 self.files.as_deref()
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum CheckResult {
108 Pass,
110 Fail,
112 TimedOut,
114 Error,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct CheckOutcome {
122 pub result: CheckResult,
123 pub exit_code: Option<i32>,
125 pub duration_ms: u64,
127 pub resource_limit: Option<String>,
129 pub output_tail: String,
131 pub output_log: Option<PathBuf>,
134 pub error: Option<String>,
136 pub containment: String,
139}
140
141#[derive(Debug, Clone)]
144pub struct CheckRunner {
145 session_id: String,
146 policy: CapabilityPolicy,
147 containment: crate::containment::ContainmentDecision,
149 host_probe: HostProbe,
150 temp_mapping: crate::temp_dir::TempMapping,
151 accessible_root: WorkdirRoot,
152 records_root: WorkdirRoot,
153 records_dir: PathBuf,
154 sessions_dir: Option<PathBuf>,
156}
157
158impl StagedSession {
159 #[must_use]
162 pub fn check_runner(&self) -> CheckRunner {
163 CheckRunner {
164 session_id: self.session_id.clone(),
165 policy: self.capability_policy.clone(),
166 containment: self.containment,
167 host_probe: self.host_probe.clone(),
168 temp_mapping: self.temp_mapping,
169 accessible_root: self.accessible_root.clone(),
170 records_root: self.records_root.clone(),
171 records_dir: self.records_dir.clone(),
172 sessions_dir: self
173 .runtime_dir
174 .parent()
175 .and_then(Path::parent)
176 .map(Path::to_path_buf),
177 }
178 }
179}
180
181impl CheckRunner {
182 pub fn run(&self, check: &SessionCheck) -> CheckOutcome {
188 let containment = self.containment.achieved().as_str().to_string();
189 let error = |error: String| CheckOutcome {
190 result: CheckResult::Error,
191 exit_code: None,
192 duration_ms: 0,
193 resource_limit: None,
194 output_tail: String::new(),
195 output_log: None,
196 error: Some(error),
197 containment: containment.clone(),
198 };
199
200 if let Some(files) = check.files() {
201 if let Err(refusal) = copy_tree_into(files, &self.accessible_root) {
202 return error(format!("could not copy the check's files: {refusal}"));
203 }
204 }
205
206 let checked = match self.enforcement() {
207 Ok((enforcement, runtime_dir)) => {
208 let checked =
209 run_with_timeout(check, &self.accessible_root, &self.policy, &enforcement);
210 enforcement.end_session();
213 drop(runtime_dir);
214 checked
215 }
216 Err(reason) => return error(reason),
217 };
218
219 let (result, exit_code, duration_ms, shell) = match checked {
220 Checked::Exited(shell) => {
221 let result = if shell.exit_code == 0 {
222 CheckResult::Pass
223 } else {
224 CheckResult::Fail
225 };
226 (
227 result,
228 Some(shell.exit_code),
229 shell.duration_ms,
230 Some(shell),
231 )
232 }
233 Checked::TimedOut { elapsed, shell } => (
234 CheckResult::TimedOut,
235 None,
236 elapsed.as_millis().try_into().unwrap_or(u64::MAX),
237 shell,
238 ),
239 Checked::Failed(reason) => return error(reason),
240 };
241
242 let (stdout, stderr) = shell
243 .as_ref()
244 .map(|shell| (shell.stdout.as_str(), shell.stderr.as_str()))
245 .unwrap_or_default();
246 let mut output = Vec::with_capacity(stdout.len() + stderr.len());
247 output.extend_from_slice(stdout.as_bytes());
248 output.extend_from_slice(stderr.as_bytes());
249 let (output_log, log_error) = match self.records_root.write_file_exact_mode(
250 CHECK_LOG_FILE,
251 &output,
252 crate::murmur_home::PRIVATE_FILE_MODE,
253 ) {
254 Ok(()) => (Some(self.records_dir.join(CHECK_LOG_FILE)), None),
255 Err(error) => (
256 None,
257 Some(format!("could not write {CHECK_LOG_FILE}: {error}")),
258 ),
259 };
260 let tail_start = output.len().saturating_sub(CHECK_OUTPUT_TAIL_BYTES);
261
262 CheckOutcome {
263 result,
264 exit_code,
265 duration_ms,
266 resource_limit: shell
267 .as_ref()
268 .and_then(|shell| shell.resource_limit_hit.clone()),
269 output_tail: String::from_utf8_lossy(&output[tail_start..]).into_owned(),
270 output_log,
271 error: log_error,
272 containment,
273 }
274 }
275
276 fn enforcement(&self) -> Result<(ShellEnforcement, crate::runtime_dir::RuntimeDir), String> {
280 let check_id = format!("{}-check", self.session_id);
281 let prepared = crate::runtime::prepare_launch_scope(
282 &self.policy,
283 false,
284 &check_id,
285 self.accessible_root.path(),
286 )
287 .map_err(|error| error.to_string())?;
288 let temp = self
289 .records_root
290 .open_beneath(crate::temp_dir::SESSION_TEMP_DIR_NAME)
291 .map_err(|error| error.to_string())?;
292 let home = self
293 .records_root
294 .open_beneath(crate::records_dir::HOME_SUBDIR)
295 .map_err(|error| error.to_string())?;
296 let workdir_guard =
297 crate::resources::workdir_guard_max_bytes(&self.policy).map(|max_bytes| {
298 crate::resources::WorkdirGuard::spawn(
299 &self.accessible_root,
300 temp.as_ref(),
301 home.as_ref(),
302 Some(&self.records_root),
303 max_bytes,
304 )
305 });
306 let sessions_dir = match &self.sessions_dir {
307 Some(dir) => dir.clone(),
308 None => crate::runtime_dir::sessions_dir().map_err(|error| error.to_string())?,
309 };
310 let runtime_dir =
311 crate::runtime_dir::RuntimeDir::create(&sessions_dir, &check_id, &self.accessible_root)
312 .map_err(|reason| format!("the check's runtime directory: {reason}"))?;
313 let mut roots = runtime_dir
314 .roots()
315 .clone()
316 .with_records(self.records_root.clone());
317 if let Some(temp) = temp {
318 roots = roots.with_temp(temp);
319 }
320 if let Some(home) = home {
321 roots = roots.with_home(home);
322 }
323 let enforcement =
324 ShellEnforcement::resolve(&self.policy, &self.containment, self.host_probe.clone())?
325 .with_host_bounding(prepared.scope, workdir_guard)
326 .with_runtime_dir(roots, None)
327 .with_temp_mapping(self.temp_mapping);
328 Ok((enforcement, runtime_dir))
329 }
330}
331
332enum Checked {
334 Exited(ShellResult),
335 TimedOut {
337 elapsed: Duration,
338 shell: Option<ShellResult>,
339 },
340 Failed(String),
341}
342
343fn run_with_timeout(
347 check: &SessionCheck,
348 workdir: &WorkdirRoot,
349 policy: &CapabilityPolicy,
350 enforcement: &ShellEnforcement,
351) -> Checked {
352 let (sender, receiver) = mpsc::channel::<Result<ShellResult, ShellExecError>>();
353 let group = Arc::new(AtomicI32::new(0));
354 let started = Instant::now();
355 {
356 let binary = check.binary.clone();
357 let args = check.args.clone();
358 let workdir = workdir.clone();
359 let policy = policy.clone();
360 let enforcement = enforcement.clone();
361 let group = Arc::clone(&group);
362 let spawned = std::thread::Builder::new()
363 .name("session-check".to_string())
364 .spawn(move || {
365 crate::shell::report_spawned_group_to(group);
366 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
367 let result = crate::shell::execute_shell(
368 &binary,
369 &args,
370 &[],
371 &workdir,
372 &policy,
373 &enforcement,
374 );
375 let _ = sender.send(result);
376 });
377 if let Err(error) = spawned {
378 return Checked::Failed(format!("could not start the check: {error}"));
379 }
380 }
381
382 let settled = |result: Result<ShellResult, ShellExecError>| match result {
383 Ok(shell) => Checked::Exited(shell),
384 Err(error) => Checked::Failed(error.to_string()),
385 };
386 match receiver.recv_timeout(check.timeout) {
387 Ok(result) => settled(result),
388 Err(mpsc::RecvTimeoutError::Disconnected) => {
389 Checked::Failed("the check's thread ended without a result".to_string())
390 }
391 Err(mpsc::RecvTimeoutError::Timeout) => {
392 let elapsed = started.elapsed();
393 enforcement.kill_session_processes();
394 let pgid = group.load(Ordering::SeqCst);
395 if pgid > 0 {
396 crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
397 }
398 let shell = match receiver.recv_timeout(KILLED_OUTPUT_WAIT) {
399 Ok(Ok(shell)) => Some(shell),
400 _ => None,
401 };
402 Checked::TimedOut { elapsed, shell }
403 }
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct TreeEntry {
410 pub relative: PathBuf,
412 pub is_dir: bool,
413 pub mode: u32,
415}
416
417pub fn walk_tree(root: &Path) -> Result<Vec<TreeEntry>, String> {
421 use std::os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _};
422
423 fn walk(dir: &Path, relative: &Path, out: &mut Vec<TreeEntry>) -> Result<(), String> {
424 let read = |path: &Path, error: std::io::Error| format!("{}: {error}", path.display());
425 for entry in std::fs::read_dir(dir).map_err(|error| read(dir, error))? {
426 let entry = entry.map_err(|error| read(dir, error))?;
427 let path = entry.path();
428 let rel = relative.join(entry.file_name());
429 let meta = std::fs::symlink_metadata(&path).map_err(|error| read(&path, error))?;
430 let mode = meta.permissions().mode() & 0o7777;
431 let file_type = meta.file_type();
432 if file_type.is_symlink() {
433 return Err(format!("{} is a symbolic link", path.display()));
434 }
435 if file_type.is_dir() {
436 out.push(TreeEntry {
437 relative: rel.clone(),
438 is_dir: true,
439 mode,
440 });
441 walk(&path, &rel, out)?;
442 } else if file_type.is_file() {
443 out.push(TreeEntry {
444 relative: rel,
445 is_dir: false,
446 mode,
447 });
448 } else {
449 return Err(format!("{} is a special file", path.display()));
450 }
451 }
452 Ok(())
453 }
454
455 let mut entries = Vec::new();
456 walk(root, Path::new(""), &mut entries)?;
457 entries.sort_by(|a, b| {
458 a.relative
459 .as_os_str()
460 .as_bytes()
461 .cmp(b.relative.as_os_str().as_bytes())
462 });
463 Ok(entries)
464}
465
466pub fn copy_tree_into(source: &Path, workdir: &WorkdirRoot) -> Result<(), String> {
476 const FILLING_DIR_MODE: u32 = 0o700;
477
478 let entries = walk_tree(source)?;
479 for entry in &entries {
480 let rel = entry.relative.to_string_lossy();
481 if entry.is_dir {
482 workdir
483 .create_dir_all(rel.as_ref(), FILLING_DIR_MODE)
484 .and_then(|()| workdir.set_dir_mode(rel.as_ref(), FILLING_DIR_MODE))
485 .map_err(|error| error.to_string())?;
486 } else {
487 let path = source.join(&entry.relative);
488 let bytes =
489 std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
490 workdir
491 .write_file_exact_mode(rel.as_ref(), &bytes, entry.mode)
492 .map_err(|error| error.to_string())?;
493 }
494 }
495 for entry in entries.iter().rev().filter(|entry| entry.is_dir) {
496 let rel = entry.relative.to_string_lossy();
497 workdir
498 .set_dir_mode(rel.as_ref(), entry.mode)
499 .map_err(|error| error.to_string())?;
500 }
501 Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn a_command_splits_as_the_shell_tool_splits_it() {
510 let command = r#"grep -q "fixed value" 'a b'/c\ d.txt"#;
511 let check = SessionCheck::new(command, None, Duration::from_secs(1)).unwrap();
512 let mut words = crate::shell::split_shell_words(command);
513 assert_eq!(check.binary(), words.remove(0));
514 assert_eq!(check.args(), words.as_slice());
515 assert_eq!(check.binary(), "grep");
516 assert_eq!(check.args(), ["-q", "fixed value", "a b/c d.txt"]);
517 assert_eq!(check.command(), command);
518 }
519
520 #[test]
521 fn an_empty_command_is_refused() {
522 for command in ["", " ", "\t\n"] {
523 let refused = SessionCheck::new(command, None, Duration::from_secs(1)).unwrap_err();
524 assert!(refused.contains("empty"), "{refused}");
525 }
526 }
527
528 #[test]
529 fn a_zero_timeout_is_refused() {
530 let refused = SessionCheck::new("true", None, Duration::ZERO).unwrap_err();
531 assert!(refused.contains("timeout"), "{refused}");
532 }
533
534 #[test]
535 fn outcomes_serialize_in_snake_case() {
536 let outcome = CheckOutcome {
537 result: CheckResult::TimedOut,
538 exit_code: None,
539 duration_ms: 2000,
540 resource_limit: None,
541 output_tail: String::new(),
542 output_log: None,
543 error: None,
544 containment: "scoped".to_string(),
545 };
546 let json = serde_json::to_value(&outcome).unwrap();
547 assert_eq!(json["result"], "timed_out");
548 assert_eq!(json["containment"], "scoped");
549 let back: CheckOutcome = serde_json::from_value(json).unwrap();
550 assert_eq!(back, outcome);
551 }
552
553 #[test]
554 fn check_files_with_a_link_are_refused_before_anything_is_written() {
555 let source = tempfile::tempdir().unwrap();
556 std::fs::write(source.path().join("a.txt"), "a").unwrap();
557 std::os::unix::fs::symlink("/etc/passwd", source.path().join("z-link")).unwrap();
558 let workdir = tempfile::tempdir().unwrap();
559 let root = WorkdirRoot::open(workdir.path()).unwrap();
560 let refused = copy_tree_into(source.path(), &root).unwrap_err();
561 assert!(refused.contains("z-link"), "{refused}");
562 assert!(!workdir.path().join("a.txt").exists());
563 }
564
565 #[test]
566 fn check_files_keep_their_mode_and_overwrite_what_is_there() {
567 use std::os::unix::fs::PermissionsExt as _;
568 let source = tempfile::tempdir().unwrap();
569 std::fs::create_dir(source.path().join("sub")).unwrap();
570 std::fs::write(source.path().join("sub/run.sh"), "new").unwrap();
571 std::fs::set_permissions(
572 source.path().join("sub/run.sh"),
573 std::fs::Permissions::from_mode(0o751),
574 )
575 .unwrap();
576 let workdir = tempfile::tempdir().unwrap();
577 std::fs::create_dir(workdir.path().join("sub")).unwrap();
578 std::fs::write(workdir.path().join("sub/run.sh"), "old").unwrap();
579 let root = WorkdirRoot::open(workdir.path()).unwrap();
580 copy_tree_into(source.path(), &root).unwrap();
581 let copied = workdir.path().join("sub/run.sh");
582 assert_eq!(std::fs::read_to_string(&copied).unwrap(), "new");
583 assert_eq!(
584 std::fs::metadata(&copied).unwrap().permissions().mode() & 0o7777,
585 0o751
586 );
587 }
588
589 #[test]
590 fn a_read_only_directory_in_check_files_is_filled_then_closed() {
591 use std::os::unix::fs::PermissionsExt as _;
592 let source = tempfile::tempdir().unwrap();
593 std::fs::create_dir(source.path().join("ro")).unwrap();
594 std::fs::write(source.path().join("ro/t.txt"), "t").unwrap();
595 std::fs::set_permissions(
596 source.path().join("ro"),
597 std::fs::Permissions::from_mode(0o555),
598 )
599 .unwrap();
600 let workdir = tempfile::tempdir().unwrap();
601 let root = WorkdirRoot::open(workdir.path()).unwrap();
602 copy_tree_into(source.path(), &root).unwrap();
603 let copied = workdir.path().join("ro");
604 assert_eq!(std::fs::read_to_string(copied.join("t.txt")).unwrap(), "t");
605 assert_eq!(
606 std::fs::metadata(&copied).unwrap().permissions().mode() & 0o7777,
607 0o555
608 );
609 copy_tree_into(source.path(), &root).unwrap();
612 for dir in [source.path().join("ro"), copied] {
613 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
614 }
615 }
616
617 #[test]
618 fn a_link_the_agent_left_is_not_followed() {
619 let source = tempfile::tempdir().unwrap();
620 std::fs::create_dir(source.path().join("hidden")).unwrap();
621 std::fs::write(source.path().join("hidden/check.txt"), "x").unwrap();
622 let outside = tempfile::tempdir().unwrap();
623 let workdir = tempfile::tempdir().unwrap();
624 std::os::unix::fs::symlink(outside.path(), workdir.path().join("hidden")).unwrap();
625 let root = WorkdirRoot::open(workdir.path()).unwrap();
626 let refused = copy_tree_into(source.path(), &root).unwrap_err();
627 assert!(refused.contains("hidden"), "{refused}");
628 assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none());
629 }
630}