1use std::{
2 collections::BTreeMap,
3 ffi::OsString,
4 process::{Command, Stdio},
5 time::{Duration, Instant},
6};
7
8use crate::{
9 artifact_config::ARTIFACT_CONFIG_ENV,
10 detached::{DetachPolicy, DetachedDispatchInfo},
11 types::CapabilityPolicy,
12 workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
13};
14
15pub(crate) const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
16
17const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
18const DEFAULT_ENV_BASELINE: &[&str] = &[
19 "PATH",
20 "HOME",
21 "USER",
22 "LOGNAME",
23 "LANG",
24 "LC_ALL",
25 "CARGO_HOME",
26 "RUSTUP_HOME",
27 "TERM",
28];
29
30pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
34
35#[cfg(test)]
38pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
39
40#[derive(Debug, Clone, PartialEq, Eq)]
47pub(crate) struct HostAccountEnv {
48 set: Vec<(String, String)>,
49 unset: Vec<String>,
50}
51
52impl HostAccountEnv {
53 pub(crate) fn from_host() -> Self {
55 Self::from_lookup(|name| std::env::var(name).ok())
56 }
57
58 pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
60 let mut set = Vec::new();
61 let mut unset = Vec::new();
62 for name in ACCOUNT_ENV {
63 match lookup(name) {
64 Some(value) => set.push((name.to_string(), value)),
65 None => unset.push(name.to_string()),
66 }
67 }
68 Self { set, unset }
69 }
70
71 #[cfg(test)]
73 pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
74 Self::from_lookup(|name| {
75 pairs
76 .iter()
77 .find(|(key, _)| *key == name)
78 .map(|(_, value)| value.to_string())
79 })
80 }
81
82 pub(crate) fn unset(&self) -> &[String] {
84 &self.unset
85 }
86
87 pub(crate) fn set(&self) -> &[(String, String)] {
89 &self.set
90 }
91
92 pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
95 for name in ACCOUNT_ENV {
96 env.remove(name);
97 }
98 env.extend(self.set.iter().cloned());
99 }
100}
101
102pub(crate) const NO_HOME_FOLDER_REFUSAL: &str =
105 "no home folder: this command's enforcement holds no session home folder (home/ in the \
106 session's records directory) to be its $HOME, so it is not run";
107
108pub(crate) const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
113 "ANTHROPIC_API_KEY",
114 "OPENAI_API_KEY",
115 "GOOGLE_API_KEY",
116 "HUGGING_FACE_HUB_TOKEN",
117 "AWS_*",
118 "GITHUB_TOKEN",
119 "GH_TOKEN",
120 "DOCKER_*",
121 "KUBECONFIG",
122 "NPM_TOKEN",
123 "PYPI_TOKEN",
124 "CARGO_REGISTRY_TOKEN",
125 "*_API_KEY",
126];
127
128#[derive(Debug)]
129pub(crate) struct ShellResult {
130 pub(crate) binary: String,
136 pub(crate) exit_code: i32,
137 pub(crate) stdout: String,
138 pub(crate) stderr: String,
139 pub(crate) duration_ms: u64,
140 pub(crate) truncated: bool,
141 pub(crate) full_output_path: Option<String>,
146 pub(crate) resource_limit_hit: Option<String>,
151 pub(crate) output_holders: OutputHolders,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum OutputHolders {
164 Closed,
166 Killed,
169 Escaped,
173}
174
175impl OutputHolders {
176 pub(crate) fn note(self) -> Option<String> {
179 const REDIRECT: &str = "A process meant to keep running in the background needs its \
180 output redirected away from the call, e.g. `server >server.log 2>&1 &`.";
181 match self {
182 Self::Closed => None,
183 Self::Killed => Some(format!(
184 "The command exited, but processes it started were still holding its output \
185 after the wait and were killed. {REDIRECT}"
186 )),
187 Self::Escaped => Some(format!(
188 "The command exited, but processes it started outside its process group still \
189 hold its output; the result has what was read before the call returned, and \
190 those processes keep running until the session ends. {REDIRECT}"
191 )),
192 }
193 }
194}
195
196const PIPE_DRAIN_AFTER_EXIT: Duration = Duration::from_secs(1);
202
203pub(crate) const LEFTOVER_KILL_WAIT: Duration = Duration::from_secs(2);
206
207const PIPE_POLL_INTERVAL: Duration = Duration::from_millis(10);
209
210#[derive(Debug)]
220pub(crate) enum ShellExecError {
221 Failed(String),
225 SealedRootConstructionFailed { detail: String },
229}
230
231impl ShellExecError {
232 pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
238 match self {
239 Self::Failed(_) => None,
240 Self::SealedRootConstructionFailed { detail } => {
241 Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
242 detail: detail.clone(),
243 })
244 }
245 }
246 }
247}
248
249pub(crate) fn enforced_spawn_failure(
260 command: Command,
261 supervisor: &crate::sandbox::SupervisorHandle,
262 subject: &str,
263 spawn_error: String,
264) -> ShellExecError {
265 drop(command);
266 match supervisor.read_diagnostic() {
267 Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
273 ShellExecError::SealedRootConstructionFailed { detail }
274 }
275 Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
276 "sandbox: {subject} enforcement setup failed before exec: {detail}"
277 )),
278 _ => ShellExecError::Failed(spawn_error),
279 }
280}
281
282impl std::fmt::Display for ShellExecError {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 match self {
285 Self::Failed(message) => f.write_str(message),
286 Self::SealedRootConstructionFailed { detail } => write!(
290 f,
291 "{}",
292 crate::errors::RuntimeError::SealedRootConstructionFailed {
293 detail: detail.clone(),
294 }
295 ),
296 }
297 }
298}
299
300impl From<String> for ShellExecError {
302 fn from(message: String) -> Self {
303 Self::Failed(message)
304 }
305}
306
307pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
308 SHELL_INTERPRETERS.contains(&binary)
309}
310
311pub(crate) use murmur_artifact::split_shell_words;
312
313pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
314 let description = if is_shell_interpreter(binary) {
315 format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
316 } else {
317 format!("Run {binary} in the capsule workdir. The `command` field holds {binary}'s arguments and not the binary name. It is split into arguments at whitespace; single or double quotes group words, and a backslash escapes the next character. No shell runs it, so nothing in it is expanded.")
318 };
319
320 format!(
321 "name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
322 )
323}
324
325thread_local! {
326 static SPAWNED_GROUP: std::cell::RefCell<Option<std::sync::Arc<std::sync::atomic::AtomicI32>>> =
329 const { std::cell::RefCell::new(None) };
330}
331
332pub(crate) fn report_spawned_group_to(sink: std::sync::Arc<std::sync::atomic::AtomicI32>) {
335 SPAWNED_GROUP.with(|slot| *slot.borrow_mut() = Some(sink));
336}
337
338#[derive(Debug)]
340pub(crate) enum ShellOutcome {
341 Finished(ShellResult),
342 Detached(DetachedDispatchInfo),
343}
344
345const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
352
353pub(crate) const CANCELED_BEFORE_SPAWN: &str =
355 "the task was canceled before this command started, so it was not run";
356
357#[allow(clippy::too_many_arguments)]
386pub(crate) fn run_shell(
387 binary: &str,
388 args: &[&str],
389 env_overrides: &[(String, String)],
390 accessible_workdir: &WorkdirRoot,
391 policy: &CapabilityPolicy,
392 enforcement: &crate::sandbox::ShellEnforcement,
393 detach: Option<DetachPolicy>,
394) -> Result<ShellOutcome, ShellExecError> {
395 let wait = detach.map_or(Wait::Blocking, Wait::Detach);
396 spawn_and_wait(
397 binary,
398 args,
399 env_overrides,
400 accessible_workdir,
401 policy,
402 enforcement,
403 wait,
404 )
405 .map(|(outcome, _)| outcome)
406}
407
408pub(crate) struct ForegroundStop {
410 pub canceled: crate::cancel::CancelSignal,
412 pub timeout: Duration,
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub(crate) enum ForegroundStopReason {
419 Canceled,
421 TimedOut,
423}
424
425#[derive(Debug)]
427pub(crate) struct ForegroundRun {
428 pub result: ShellResult,
429 pub stopped: Option<ForegroundStopReason>,
430}
431
432pub(crate) fn run_shell_until_stopped(
440 binary: &str,
441 args: &[&str],
442 env_overrides: &[(String, String)],
443 accessible_workdir: &WorkdirRoot,
444 policy: &CapabilityPolicy,
445 enforcement: &crate::sandbox::ShellEnforcement,
446 stop: ForegroundStop,
447) -> Result<ForegroundRun, ShellExecError> {
448 match spawn_and_wait(
449 binary,
450 args,
451 env_overrides,
452 accessible_workdir,
453 policy,
454 enforcement,
455 Wait::Stop(stop),
456 )? {
457 (ShellOutcome::Finished(result), stopped) => Ok(ForegroundRun { result, stopped }),
458 (ShellOutcome::Detached(info), _) => Err(ShellExecError::Failed(format!(
460 "a foreground command was demoted to the background as {}",
461 info.work_id
462 ))),
463 }
464}
465
466enum Wait {
468 Blocking,
470 Detach(DetachPolicy),
472 Stop(ForegroundStop),
475}
476
477fn spawn_and_wait(
481 binary: &str,
482 args: &[&str],
483 env_overrides: &[(String, String)],
484 accessible_workdir: &WorkdirRoot,
485 policy: &CapabilityPolicy,
486 enforcement: &crate::sandbox::ShellEnforcement,
487 wait: Wait,
488) -> Result<(ShellOutcome, Option<ForegroundStopReason>), ShellExecError> {
489 use std::os::unix::process::CommandExt as _;
490
491 if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
492 return Err(ShellExecError::Failed(format!(
493 "binary '{binary}' is not in capabilities.shell.allow"
494 )));
495 }
496
497 if let Wait::Detach(DetachPolicy {
500 canceled: Some(canceled),
501 ..
502 }) = &wait
503 {
504 if canceled.is_canceled() {
505 return Err(ShellExecError::Failed(CANCELED_BEFORE_SPAWN.to_string()));
506 }
507 }
508
509 enforcement.check_workdir_budget()?;
512
513 let env = subprocess_env(policy, env_overrides, enforcement)?;
514
515 let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
518
519 let started = Instant::now();
520 let mut command = Command::new(binary);
521 command
522 .args(args)
523 .current_dir(accessible_workdir.path())
524 .env_clear()
525 .envs(env)
526 .stdin(Stdio::null())
529 .stdout(Stdio::piped())
530 .stderr(Stdio::piped())
531 .process_group(0);
534
535 let supervisor =
540 crate::sandbox::prepare_enforcement(&mut command, enforcement, accessible_workdir.path())?;
541
542 let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
546 let cgroup_counters_before = enforcement
549 .cgroup_scope
550 .as_ref()
551 .map(|scope| scope.event_counters());
552
553 let mut child = match command.spawn() {
554 Ok(child) => child,
555 Err(error) => {
556 return Err(enforced_spawn_failure(
557 command,
558 &supervisor,
559 "shell",
560 error.to_string(),
561 ))
562 }
563 };
564 let pgid = child.id() as i32;
566 SPAWNED_GROUP.with(|slot| {
567 if let Some(sink) = slot.borrow().as_ref() {
568 sink.store(pgid, std::sync::atomic::Ordering::SeqCst);
569 }
570 });
571
572 let readers = OutputReaders::spawn(&mut child);
576
577 let records_root = enforcement.records_root();
580 let wait = match wait {
581 Wait::Detach(_) if records_root.is_none() => Wait::Blocking,
582 wait => wait,
583 };
584
585 let mut stopped = None;
586 let (status, drain_deadline) = match wait {
587 Wait::Blocking => {
588 let status = child.wait().map_err(|error| error.to_string())?;
589 (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT)
590 }
591 Wait::Stop(stop) => {
592 let deadline = started + stop.timeout;
593 loop {
594 if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
595 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
596 }
597 let remaining = deadline.saturating_duration_since(Instant::now());
598 let reason = if stop.canceled.is_canceled() {
599 Some(ForegroundStopReason::Canceled)
600 } else if remaining.is_zero() {
601 Some(ForegroundStopReason::TimedOut)
602 } else {
603 None
604 };
605 if let Some(reason) = reason {
606 crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
608 let status = child.wait().map_err(|error| error.to_string())?;
609 stopped = Some(reason);
610 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
611 }
612 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
613 }
614 }
615 Wait::Detach(policy) => {
616 let deadline = policy.grace.map(|grace| started + grace);
618 loop {
619 match child.try_wait().map_err(|error| error.to_string())? {
620 Some(status) => {
621 let drain = Instant::now() + PIPE_DRAIN_AFTER_EXIT;
622 break (
623 status,
624 deadline.map_or(drain, |deadline| drain.min(deadline)),
625 );
626 }
627 None => {
628 let remaining = deadline
629 .map(|deadline| deadline.saturating_duration_since(Instant::now()));
630 let abandoned = policy
633 .abandoned
634 .as_ref()
635 .is_some_and(crate::detached::AbandonSignal::is_raised);
636 let canceled = policy
639 .canceled
640 .as_ref()
641 .filter(|signal| signal.is_canceled());
642 if let Some(signal) = canceled {
643 signal.note_phase(crate::cancel::PHASE_SHELL);
644 }
645 if remaining.is_some_and(|remaining| remaining.is_zero())
646 || abandoned
647 || canceled.is_some()
648 {
649 let records_root = records_root
651 .expect("a detach policy is kept only with a records root")
652 .clone();
653 let detached = demote(
654 child,
655 readers,
656 DemotionContext {
657 policy,
658 records_root,
659 invoked_binary: binary.to_string(),
660 resolved_binary,
661 args: args.iter().map(|arg| (*arg).to_string()).collect(),
662 started,
663 pgid,
664 cgroup_scope: enforcement.cgroup_scope.clone(),
665 cgroup_counters_before,
666 cgroup_occupancy,
667 },
668 );
669 return Ok((ShellOutcome::Detached(detached), None));
670 }
671 std::thread::sleep(remaining.map_or(DETACH_POLL_INTERVAL, |remaining| {
672 remaining.min(DETACH_POLL_INTERVAL)
673 }));
674 }
675 }
676 }
677 }
678 };
679 let (stdout_bytes, stderr_bytes, output_holders) = finish_output(readers, pgid, drain_deadline);
680 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
681
682 let resource_limit_hit = classify_resource_limit(
685 &status,
686 enforcement.cgroup_scope.as_deref(),
687 cgroup_counters_before,
688 cgroup_occupancy.as_ref(),
689 )
690 .filter(|_| stopped.is_none());
691 drop(cgroup_occupancy);
692 let exit_code = exit_code_of(&status);
693
694 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
695 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
696 let mut truncated = false;
697 let mut full_output_path = None;
698
699 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
700 truncated = true;
701 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
702 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
703 let full_stdout = std::mem::replace(
704 &mut stdout,
705 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
706 );
707 let full_stderr = std::mem::replace(
708 &mut stderr,
709 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
710 );
711 if let Some(records_root) = records_root {
712 full_output_path = Some(write_shell_output_log(
713 records_root,
714 &format!("shell-{}", crate::trace::timestamp_ms()),
715 binary,
716 args,
717 exit_code,
718 &full_stdout,
719 &full_stderr,
720 )?);
721 }
722 }
723
724 Ok((
725 ShellOutcome::Finished(ShellResult {
726 binary: resolved_binary,
727 exit_code,
728 stdout,
729 stderr,
730 duration_ms,
731 truncated,
732 full_output_path,
733 resource_limit_hit,
734 output_holders,
735 }),
736 stopped,
737 ))
738}
739
740pub(crate) fn execute_shell(
745 binary: &str,
746 args: &[&str],
747 env_overrides: &[(String, String)],
748 accessible_workdir: &WorkdirRoot,
749 policy: &CapabilityPolicy,
750 enforcement: &crate::sandbox::ShellEnforcement,
751) -> Result<ShellResult, ShellExecError> {
752 match run_shell(
753 binary,
754 args,
755 env_overrides,
756 accessible_workdir,
757 policy,
758 enforcement,
759 None,
760 )? {
761 ShellOutcome::Finished(result) => Ok(result),
762 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
766 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
767 info.work_id
768 ))),
769 }
770}
771
772struct OutputReaders {
783 stdout: Option<PipeReader>,
784 stderr: Option<PipeReader>,
785}
786
787enum PipeSink {
789 Buffer(Vec<u8>),
791 Live(std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>),
793 Discard,
796}
797
798struct PipeReader {
800 thread: std::thread::JoinHandle<()>,
801 sink: std::sync::Arc<std::sync::Mutex<PipeSink>>,
802}
803
804fn lock_sink(sink: &std::sync::Mutex<PipeSink>) -> std::sync::MutexGuard<'_, PipeSink> {
805 sink.lock()
806 .unwrap_or_else(std::sync::PoisonError::into_inner)
807}
808
809impl PipeReader {
810 fn spawn(mut pipe: impl std::io::Read + Send + 'static) -> Self {
811 let sink = std::sync::Arc::new(std::sync::Mutex::new(PipeSink::Buffer(Vec::new())));
812 let thread = {
813 let sink = std::sync::Arc::clone(&sink);
814 std::thread::spawn(move || {
815 let mut chunk = [0u8; 8192];
816 loop {
817 match pipe.read(&mut chunk) {
818 Ok(0) => break,
819 Ok(read) => match &mut *lock_sink(&sink) {
820 PipeSink::Buffer(buffer) => buffer.extend_from_slice(&chunk[..read]),
821 PipeSink::Live(output) => output
822 .lock()
823 .unwrap_or_else(std::sync::PoisonError::into_inner)
824 .append(&chunk[..read]),
825 PipeSink::Discard => {}
826 },
827 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
828 Err(_) => break,
832 }
833 }
834 })
835 };
836 Self { thread, sink }
837 }
838
839 fn take(self) -> Vec<u8> {
842 let taken = match std::mem::replace(&mut *lock_sink(&self.sink), PipeSink::Discard) {
843 PipeSink::Buffer(buffer) => buffer,
844 PipeSink::Live(_) | PipeSink::Discard => Vec::new(),
845 };
846 if self.thread.is_finished() && self.thread.join().is_err() {
847 return Vec::new();
848 }
849 taken
850 }
851}
852
853impl OutputReaders {
854 fn spawn(child: &mut std::process::Child) -> Self {
855 Self {
856 stdout: child.stdout.take().map(PipeReader::spawn),
857 stderr: child.stderr.take().map(PipeReader::spawn),
858 }
859 }
860
861 fn go_live(&self, output: &std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>) {
867 let mut sinks: Vec<_> = [&self.stdout, &self.stderr]
868 .into_iter()
869 .flatten()
870 .map(|reader| lock_sink(&reader.sink))
871 .collect();
872 let mut live = output
873 .lock()
874 .unwrap_or_else(std::sync::PoisonError::into_inner);
875 for sink in &mut sinks {
876 if let PipeSink::Buffer(buffer) =
877 std::mem::replace(&mut **sink, PipeSink::Live(std::sync::Arc::clone(output)))
878 {
879 live.append(&buffer);
880 }
881 }
882 }
883
884 fn at_eof(&self) -> bool {
886 [&self.stdout, &self.stderr]
887 .into_iter()
888 .flatten()
889 .all(|reader| reader.thread.is_finished())
890 }
891
892 fn wait_for_eof(&self, deadline: Instant) -> bool {
895 loop {
896 if self.at_eof() {
897 return true;
898 }
899 let remaining = deadline.saturating_duration_since(Instant::now());
900 if remaining.is_zero() {
901 return false;
902 }
903 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
904 }
905 }
906
907 fn take(self) -> (Vec<u8>, Vec<u8>) {
910 let take = |reader: Option<PipeReader>| reader.map(PipeReader::take).unwrap_or_default();
911 (take(self.stdout), take(self.stderr))
912 }
913}
914
915fn finish_output(
924 readers: OutputReaders,
925 pgid: i32,
926 drain_deadline: Instant,
927) -> (Vec<u8>, Vec<u8>, OutputHolders) {
928 if readers.wait_for_eof(drain_deadline) {
929 let (stdout, stderr) = readers.take();
930 return (stdout, stderr, OutputHolders::Closed);
931 }
932
933 let give_up = Instant::now() + LEFTOVER_KILL_WAIT;
934 let mut group_alive = true;
935 let mut killed = false;
936 let closed = loop {
937 if group_alive {
938 if crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL) {
940 killed = true;
941 } else {
942 group_alive = false;
943 }
944 }
945 if readers.at_eof() {
946 break true;
947 }
948 let remaining = give_up.saturating_duration_since(Instant::now());
949 if remaining.is_zero() {
950 break false;
951 }
952 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
953 };
954
955 let (stdout, stderr) = readers.take();
956 let holders = match (closed, killed) {
957 (true, true) => OutputHolders::Killed,
958 (true, false) => OutputHolders::Closed,
960 (false, _) => OutputHolders::Escaped,
961 };
962 (stdout, stderr, holders)
963}
964
965struct DemotionContext {
967 policy: DetachPolicy,
968 records_root: WorkdirRoot,
971 invoked_binary: String,
973 resolved_binary: String,
976 args: Vec<String>,
977 started: Instant,
978 pgid: i32,
981 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
982 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
983 cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
985}
986
987fn demote(
1000 mut child: std::process::Child,
1001 readers: OutputReaders,
1002 context: DemotionContext,
1003) -> DetachedDispatchInfo {
1004 let work_id = crate::detached::new_work_id();
1005 let info = DetachedDispatchInfo {
1006 work_id: work_id.clone(),
1007 binary: context.resolved_binary.clone(),
1008 command: context.policy.command.clone(),
1009 grace_ms: context
1010 .policy
1011 .grace
1012 .map(|grace| grace.as_millis().try_into().unwrap_or(u64::MAX)),
1013 };
1014
1015 let arg_refs: Vec<&str> = context.args.iter().map(String::as_str).collect();
1016 let log = crate::detached::output_path_for(&context.records_root, &work_id);
1017 let output = std::sync::Arc::new(std::sync::Mutex::new(crate::detached::LiveOutput::open(
1018 &context.records_root,
1019 &log.relative,
1020 &log_command_line(&context.invoked_binary, &arg_refs),
1021 )));
1022 readers.go_live(&output);
1023
1024 context.policy.registry.register(
1025 crate::detached::DetachedWork {
1026 work_id: work_id.clone(),
1027 binary: context.resolved_binary.clone(),
1028 command: context.policy.command.clone(),
1029 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
1035 context
1036 .started
1037 .elapsed()
1038 .as_millis()
1039 .try_into()
1040 .unwrap_or(u64::MAX),
1041 ),
1042 },
1043 context.started,
1044 context.pgid,
1045 log.path.clone(),
1046 std::sync::Arc::clone(&output),
1047 );
1048
1049 let provenance = context.policy.completion_provenance();
1050 let DemotionContext {
1051 policy,
1052 records_root,
1053 resolved_binary,
1054 started,
1055 pgid,
1056 cgroup_scope,
1057 cgroup_counters_before,
1058 cgroup_occupancy,
1059 ..
1060 } = context;
1061
1062 std::thread::spawn(move || {
1063 let observed = wait_for_exit_unreaped(pgid);
1064 policy.registry.leader_exited(&work_id);
1065 let waited = child.wait();
1066 let exited_at_ms = crate::trace::timestamp_ms();
1067 let (_, _, output_holders) =
1068 finish_output(readers, pgid, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
1069 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1070
1071 let (exit_code, resource_limit, mut error) = match waited {
1072 Ok(status) => (
1073 exit_code_of(&status),
1074 classify_resource_limit(
1075 &status,
1076 cgroup_scope.as_deref(),
1077 cgroup_counters_before,
1078 cgroup_occupancy.as_ref(),
1079 ),
1080 None,
1081 ),
1082 Err(failure) => (
1083 -1,
1084 None,
1085 Some(format!("waiting for the command failed: {failure}")),
1086 ),
1087 };
1088 drop(cgroup_occupancy);
1089 if let (Err(failure), None) = (observed, &error) {
1090 error = Some(format!("observing the command's exit failed: {failure}"));
1091 }
1092
1093 let write_error = output
1094 .lock()
1095 .unwrap_or_else(std::sync::PoisonError::into_inner)
1096 .finish(exit_code);
1097 if let Some(failure) = write_error {
1098 error = Some(match error {
1099 Some(existing) => format!("{existing}; {failure}"),
1100 None => failure,
1101 });
1102 }
1103 let output_bytes = records_root
1104 .file_len(&log.relative)
1105 .ok()
1106 .flatten()
1107 .unwrap_or(0);
1108
1109 policy
1110 .registry
1111 .complete(crate::detached::DetachedCompletion {
1112 work_id,
1113 binary: resolved_binary,
1114 command: policy.command.clone(),
1115 exit_code,
1116 duration_ms,
1117 exited_at_ms,
1118 output_path: log.path,
1119 output_bytes,
1120 resource_limit,
1121 output_holders,
1122 context_id: policy.context_id.clone(),
1123 provenance,
1124 error,
1125 });
1126 });
1127
1128 info
1129}
1130
1131fn wait_for_exit_unreaped(pid: i32) -> std::io::Result<()> {
1137 use rustix::process::{waitid, Pid, WaitId, WaitIdOptions};
1138
1139 let pid = (pid > 0)
1140 .then(|| Pid::from_raw(pid))
1141 .flatten()
1142 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
1143 loop {
1144 match waitid(
1145 WaitId::Pid(pid),
1146 WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
1147 ) {
1148 Ok(_) => return Ok(()),
1149 Err(rustix::io::Errno::INTR) => {}
1150 Err(errno) => return Err(errno.into()),
1151 }
1152 }
1153}
1154
1155pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
1163 use std::os::unix::process::ExitStatusExt;
1164
1165 status
1166 .code()
1167 .or_else(|| status.signal().map(|signal| 128 + signal))
1168 .unwrap_or(-1)
1169}
1170
1171fn classify_resource_limit(
1193 status: &std::process::ExitStatus,
1194 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
1195 counters_before: Option<crate::cgroup::CgroupEventCounters>,
1196 occupancy: Option<&crate::cgroup::ScopeOccupancy>,
1197) -> Option<String> {
1198 use std::os::unix::process::ExitStatusExt;
1199
1200 let signal_limit = status
1201 .signal()
1202 .and_then(crate::resources::limit_from_signal);
1203 let counter_delta = match (cgroup_scope, counters_before) {
1204 (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
1205 _ => None,
1206 };
1207 let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
1208 attribute_resource_limit(signal_limit, counter_delta, exclusive)
1209}
1210
1211pub(crate) fn attribute_resource_limit(
1218 signal_limit: Option<&'static str>,
1219 counter_delta: Option<&'static str>,
1220 exclusive: bool,
1221) -> Option<String> {
1222 signal_limit
1223 .or(counter_delta.filter(|_| exclusive))
1224 .map(str::to_string)
1225}
1226
1227pub(crate) fn build_shell_env(
1238 policy: &CapabilityPolicy,
1239 env_overrides: &[(String, String)],
1240 home_dir: Option<&std::path::Path>,
1241 runtime_dir: Option<&std::path::Path>,
1242 records_dir: Option<&std::path::Path>,
1243 tmpdir: Option<&str>,
1244) -> Result<BTreeMap<String, String>, String> {
1245 let mut env = BTreeMap::new();
1246
1247 for key in DEFAULT_ENV_BASELINE {
1248 if let Ok(value) = std::env::var(key) {
1249 env.insert((*key).to_string(), value);
1250 }
1251 }
1252
1253 for (key, value) in env_overrides {
1254 env.insert(key.clone(), value.clone());
1255 }
1256
1257 for key in &policy.shell_baseline_env {
1258 if crate::formation::is_member_grant_env(key)
1261 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1262 {
1263 continue;
1264 }
1265 if let Ok(value) = std::env::var(key) {
1266 env.insert(key.clone(), value);
1267 }
1268 }
1269
1270 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1271
1272 env.remove(ARTIFACT_CONFIG_ENV);
1275 env.remove(crate::state_store::STATE_DIR_ENV);
1276
1277 let home = home_dir.ok_or_else(|| NO_HOME_FOLDER_REFUSAL.to_string())?;
1281 let home = home.to_string_lossy().into_owned();
1282 env.insert("HOME".to_string(), home.clone());
1283 env.insert("USERPROFILE".to_string(), home);
1284 env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
1287 if let Some(runtime_dir) = runtime_dir {
1288 env.insert(
1289 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1290 runtime_dir.to_string_lossy().into_owned(),
1291 );
1292 }
1293 env.remove(crate::records_dir::RECORDS_DIR_ENV);
1295 if let Some(records_dir) = records_dir {
1296 env.insert(
1297 crate::records_dir::RECORDS_DIR_ENV.to_string(),
1298 records_dir.to_string_lossy().into_owned(),
1299 );
1300 }
1301 for name in crate::temp_dir::TEMP_DIR_ENV {
1303 env.remove(name);
1304 if let Some(tmpdir) = tmpdir {
1305 env.insert(name.to_string(), tmpdir.to_string());
1306 }
1307 }
1308
1309 Ok(env)
1310}
1311
1312pub(crate) const LINEAGE_ENV: &[&str] = &[
1319 crate::delegation::SPAWNER_ENV,
1320 crate::formation::FORMATION_ID_ENV,
1321 crate::lifeline::SPAWNER_LIFELINE_ENV,
1322 crate::host_warnings::HOST_WARNINGS_REPORTED_ENV,
1323 "MURMUR_ROOST_URL",
1324];
1325
1326pub(crate) fn subprocess_env(
1330 policy: &CapabilityPolicy,
1331 env_overrides: &[(String, String)],
1332 enforcement: &crate::sandbox::ShellEnforcement,
1333) -> Result<BTreeMap<OsString, OsString>, String> {
1334 if enforcement.tier == crate::sandbox::EnforcementTier::Unconfined {
1335 return Ok(build_unconfined_env(
1336 policy,
1337 std::env::vars_os(),
1338 env_overrides,
1339 enforcement.runtime_dir_path(),
1340 enforcement.records_dir_path(),
1341 ));
1342 }
1343 Ok(build_shell_env(
1344 policy,
1345 env_overrides,
1346 enforcement.home_dir_path(),
1347 enforcement.runtime_dir_path(),
1348 enforcement.records_dir_path(),
1349 enforcement.tmpdir_value().as_deref(),
1350 )?
1351 .into_iter()
1352 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
1353 .collect())
1354}
1355
1356pub(crate) fn build_unconfined_env(
1367 policy: &CapabilityPolicy,
1368 base: impl IntoIterator<Item = (OsString, OsString)>,
1369 env_overrides: &[(String, String)],
1370 runtime_dir: Option<&std::path::Path>,
1371 records_dir: Option<&std::path::Path>,
1372) -> BTreeMap<OsString, OsString> {
1373 let mut env: BTreeMap<OsString, OsString> = base.into_iter().collect();
1374 for (key, value) in env_overrides {
1375 env.insert(OsString::from(key), OsString::from(value));
1376 }
1377 env.retain(|key, _| {
1378 let Some(key) = key.to_str() else {
1379 return true;
1380 };
1381 !(LINEAGE_ENV.contains(&key)
1382 || crate::formation::is_member_grant_env(key)
1383 || key == ARTIFACT_CONFIG_ENV
1384 || key == crate::state_store::STATE_DIR_ENV
1385 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1386 || key == crate::records_dir::RECORDS_DIR_ENV
1387 || policy
1388 .shell_strip_env
1389 .iter()
1390 .any(|pattern| env_name_matches_pattern(pattern, key)))
1391 });
1392 if let Some(runtime_dir) = runtime_dir {
1393 env.insert(
1394 OsString::from(crate::runtime_dir::RUNTIME_DIR_ENV),
1395 runtime_dir.as_os_str().to_owned(),
1396 );
1397 }
1398 if let Some(records_dir) = records_dir {
1399 env.insert(
1400 OsString::from(crate::records_dir::RECORDS_DIR_ENV),
1401 records_dir.as_os_str().to_owned(),
1402 );
1403 }
1404 env
1405}
1406
1407pub(crate) fn strip_credential_shaped_vars(
1415 env: &mut BTreeMap<String, String>,
1416 extra_patterns: &[String],
1417) {
1418 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Eq)]
1423pub enum BackstopPatternSource {
1424 Builtin,
1426 StripEnv,
1428}
1429
1430#[derive(Debug, Clone, PartialEq, Eq)]
1432pub struct BackstopMatch {
1433 pub pattern: String,
1434 pub source: BackstopPatternSource,
1435}
1436
1437pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
1446 CREDENTIAL_ENV_PATTERNS
1447 .iter()
1448 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
1449 .chain(
1450 extra_patterns
1451 .iter()
1452 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
1453 )
1454 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
1455 .map(|(pattern, source)| BackstopMatch {
1456 pattern: pattern.to_string(),
1457 source,
1458 })
1459}
1460
1461pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
1465 credential_backstop_match(name, extra_patterns).is_some()
1466}
1467
1468pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
1484 let mut env = BTreeMap::new();
1485
1486 for key in &policy.env_allow {
1487 if key == ARTIFACT_CONFIG_ENV
1488 || key == crate::state_store::STATE_DIR_ENV
1489 || crate::formation::is_member_grant_env(key)
1490 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1491 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1492 || key == crate::records_dir::RECORDS_DIR_ENV
1493 {
1494 continue;
1495 }
1496 if let Ok(value) = std::env::var(key) {
1497 env.insert(key.clone(), value);
1498 }
1499 }
1500
1501 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1502
1503 env
1504}
1505
1506fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
1507 if let Some(rest) = pattern.strip_prefix('*') {
1508 if let Some(middle) = rest.strip_suffix('*') {
1509 return key.contains(middle);
1510 }
1511 return key.ends_with(rest);
1512 }
1513
1514 if let Some(prefix) = pattern.strip_suffix('*') {
1515 return key.starts_with(prefix);
1516 }
1517
1518 pattern == key
1519}
1520
1521fn log_command_line(binary: &str, args: &[&str]) -> String {
1523 if args.is_empty() {
1524 binary.to_string()
1525 } else {
1526 format!("{binary} {}", args.join(" "))
1527 }
1528}
1529
1530fn write_shell_output_log(
1538 records_root: &WorkdirRoot,
1539 file_stem: &str,
1540 binary: &str,
1541 args: &[&str],
1542 exit_code: i32,
1543 stdout: &str,
1544 stderr: &str,
1545) -> Result<String, String> {
1546 let log = crate::detached::output_path_for(records_root, file_stem);
1547 let command = log_command_line(binary, args);
1548
1549 let content = format!(
1550 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1551 );
1552
1553 records_root
1554 .write_file(&log.relative, content.as_bytes(), DEFAULT_FILE_MODE)
1555 .map_err(|error| format!("failed to write shell log: {error}"))?;
1556
1557 Ok(log.path)
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562 use std::{fs, path::Path};
1563 use tempfile::tempdir;
1564
1565 use super::test_support::{process_dead_within, read_pid, KillPidFileOnDrop};
1566 use super::*;
1567 use crate::sandbox::ShellEnforcement;
1568
1569 #[test]
1575 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1576 let temp = tempdir().unwrap();
1577 let policy = CapabilityPolicy {
1578 shell_allow: vec!["bash".to_string()],
1579 resources: crate::resources::HostResourceLimits {
1580 max_open_files: 64,
1581 ..crate::resources::HostResourceLimits::default()
1582 },
1583 ..CapabilityPolicy::default()
1584 };
1585 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1586 enforcement.resource_limits = policy.resources;
1587
1588 let result = execute_shell(
1589 "bash",
1590 &["-c", "ulimit -Hn"],
1591 &[],
1592 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1593 &policy,
1594 &enforcement,
1595 )
1596 .expect("bash must run");
1597
1598 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1599 assert_eq!(result.resource_limit_hit, None);
1600 }
1601
1602 #[cfg(target_os = "linux")]
1604 fn run_bash_with_limits(
1605 limits: crate::resources::HostResourceLimits,
1606 script: &str,
1607 ) -> ShellResult {
1608 let temp = tempdir().unwrap();
1609 let policy = CapabilityPolicy {
1610 shell_allow: vec!["bash".to_string()],
1611 resources: limits,
1612 ..CapabilityPolicy::default()
1613 };
1614 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1615 enforcement.resource_limits = limits;
1616
1617 execute_shell(
1618 "bash",
1619 &["-c", script],
1620 &[],
1621 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1622 &policy,
1623 &enforcement,
1624 )
1625 .expect("bash must run")
1626 }
1627
1628 #[cfg(target_os = "linux")]
1634 #[test]
1635 fn a_command_that_ignores_sigxcpu_is_killed_one_second_later() {
1636 let result = run_bash_with_limits(
1637 crate::resources::HostResourceLimits {
1638 cpu_seconds: Some(1),
1639 ..crate::resources::HostResourceLimits::default()
1640 },
1641 "( trap '' XCPU; while :; do :; done ); echo \"status=$?\"; times",
1642 );
1643
1644 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1645 assert!(
1646 result.stdout.contains("status=137"),
1647 "the subshell must die of SIGKILL; stdout was: {}",
1648 result.stdout
1649 );
1650 assert_eq!(result.resource_limit_hit, None);
1651
1652 let children = result
1653 .stdout
1654 .lines()
1655 .last()
1656 .unwrap_or_else(|| panic!("no children line from `times`: {}", result.stdout));
1657 let cpu: f64 = children
1658 .split_whitespace()
1659 .map(|field| {
1660 let (minutes, seconds) = field.trim_end_matches('s').split_once('m').unwrap();
1661 minutes.parse::<f64>().unwrap() * 60.0 + seconds.parse::<f64>().unwrap()
1662 })
1663 .sum();
1664 assert!(
1665 cpu <= 2.5,
1666 "the subshell ran {cpu} CPU-seconds, past cpu_seconds + 1"
1667 );
1668 }
1669
1670 #[cfg(target_os = "linux")]
1674 #[test]
1675 #[allow(unsafe_code)]
1676 fn the_cpu_limit_warns_one_second_before_its_hard_ceiling() {
1677 let result = run_bash_with_limits(
1678 crate::resources::HostResourceLimits {
1679 cpu_seconds: Some(5),
1680 max_open_files: 64,
1681 ..crate::resources::HostResourceLimits::default()
1682 },
1683 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)"; ulimit -Ht 10 2>/dev/null || echo "raise refused""#,
1684 );
1685
1686 let mut lines = result.stdout.lines();
1687 assert_eq!(
1688 lines.next(),
1689 Some("5 6 64 64"),
1690 "stderr was: {}",
1691 result.stderr
1692 );
1693 if unsafe { libc::geteuid() } != 0 {
1695 assert_eq!(lines.next(), Some("raise refused"));
1696 }
1697 }
1698
1699 #[cfg(target_os = "linux")]
1702 #[test]
1703 #[allow(clippy::unnecessary_cast)]
1704 fn with_no_cpu_seconds_a_command_keeps_the_inherited_cpu_limit() {
1705 let own = crate::resources::read_rlimit(libc::RLIMIT_CPU as u32)
1706 .expect("getrlimit(RLIMIT_CPU) failed");
1707 let render = |slot: libc::rlim_t| {
1708 if slot == libc::RLIM_INFINITY {
1709 "unlimited".to_string()
1710 } else {
1711 slot.to_string()
1712 }
1713 };
1714
1715 let result = run_bash_with_limits(
1716 crate::resources::HostResourceLimits::resolve(None),
1717 r#"echo "$(ulimit -St) $(ulimit -Ht)""#,
1718 );
1719
1720 assert_eq!(
1721 result.stdout.trim(),
1722 format!("{} {}", render(own.rlim_cur), render(own.rlim_max)),
1723 "stderr was: {}",
1724 result.stderr
1725 );
1726 }
1727
1728 #[cfg(target_os = "linux")]
1730 #[test]
1731 fn with_no_cpu_seconds_a_cpu_bound_command_runs_to_its_end() {
1732 let result = run_bash_with_limits(
1733 crate::resources::HostResourceLimits::resolve(None),
1734 "end=$((SECONDS + 3)); while [ \"$SECONDS\" -lt \"$end\" ]; do :; done; echo done",
1735 );
1736
1737 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1738 assert_eq!(result.stdout.trim(), "done");
1739 assert_eq!(result.resource_limit_hit, None);
1740 }
1741
1742 #[cfg(target_os = "linux")]
1747 #[test]
1748 fn a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors() {
1749 const TEST: &str = "a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors";
1750 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1751 return;
1752 }
1753 let temp = tempdir().unwrap();
1754 let policy = CapabilityPolicy {
1755 shell_allow: vec!["bash".to_string()],
1756 resources: crate::resources::HostResourceLimits {
1757 max_open_files: 16,
1758 ..crate::resources::HostResourceLimits::default()
1759 },
1760 ..CapabilityPolicy::default()
1761 };
1762 let enforcement = ShellEnforcement::resolve_declared(
1763 &policy,
1764 murmur_artifact::ContainmentClass::Scoped,
1765 crate::sandbox::HostProbe::probe(),
1766 )
1767 .unwrap()
1768 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1769 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1770 crate::runtime_err!(
1771 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1772 enforcement.tier
1773 );
1774 return;
1775 }
1776 let held: Vec<fs::File> = (0..128)
1777 .map(|_| fs::File::open("/dev/null").unwrap())
1778 .collect();
1779
1780 let result = execute_shell(
1781 "bash",
1782 &["-c", "ulimit -Hn"],
1783 &[],
1784 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1785 &policy,
1786 &enforcement,
1787 )
1788 .expect("bash must start under the limit");
1789 drop(held);
1790
1791 assert_eq!(result.stdout.trim(), "16", "stderr was: {}", result.stderr);
1792 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1793 }
1794
1795 #[cfg(target_os = "linux")]
1799 #[test]
1800 fn a_program_outside_the_allowlist_is_refused_with_exit_126() {
1801 const TEST: &str = "a_program_outside_the_allowlist_is_refused_with_exit_126";
1802 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1803 return;
1804 }
1805 let run = |allow: &[&str]| {
1806 let temp = tempdir().unwrap();
1807 let policy = CapabilityPolicy {
1808 shell_allow: allow.iter().map(|b| b.to_string()).collect(),
1809 ..CapabilityPolicy::default()
1810 };
1811 let enforcement = ShellEnforcement::resolve_declared(
1812 &policy,
1813 murmur_artifact::ContainmentClass::Scoped,
1814 crate::sandbox::HostProbe::probe(),
1815 )
1816 .unwrap()
1817 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1818 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1819 crate::runtime_err!(
1820 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1821 enforcement.tier
1822 );
1823 return None;
1824 }
1825 let result = execute_shell(
1826 "bash",
1827 &["-c", "cat /dev/null"],
1828 &[],
1829 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1830 &policy,
1831 &enforcement,
1832 )
1833 .expect("bash must start");
1834 Some(result)
1835 };
1836
1837 let Some(refused) = run(&["bash"]) else {
1838 return;
1839 };
1840 assert_eq!(refused.exit_code, 126, "stderr was: {}", refused.stderr);
1841 assert!(
1842 refused.stderr.contains("Permission denied"),
1843 "stderr was: {}",
1844 refused.stderr
1845 );
1846
1847 let allowed = run(&["bash", "cat"]).expect("the tier did not change between runs");
1848 assert_eq!(allowed.exit_code, 0, "stderr was: {}", allowed.stderr);
1849 crate::runtime_err!("{TEST}: ran on KernelFull; [bash] exited 126, [bash, cat] exited 0");
1850 }
1851
1852 #[cfg(target_os = "linux")]
1855 #[test]
1856 #[allow(clippy::print_stdout)]
1857 fn a_kernel_tier_command_gets_the_cpu_slots() {
1858 const TEST: &str = "a_kernel_tier_command_gets_the_cpu_slots";
1859 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1860 return;
1861 }
1862 let temp = tempdir().unwrap();
1863 let policy = CapabilityPolicy {
1864 shell_allow: vec!["bash".to_string()],
1865 resources: crate::resources::HostResourceLimits {
1866 cpu_seconds: Some(5),
1867 max_open_files: 64,
1868 ..crate::resources::HostResourceLimits::default()
1869 },
1870 ..CapabilityPolicy::default()
1871 };
1872 let enforcement = ShellEnforcement::resolve_declared(
1873 &policy,
1874 murmur_artifact::ContainmentClass::Scoped,
1875 crate::sandbox::HostProbe::probe(),
1876 )
1877 .unwrap()
1878 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1879 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1880 crate::runtime_err!(
1881 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1882 enforcement.tier
1883 );
1884 return;
1885 }
1886
1887 let result = execute_shell(
1888 "bash",
1889 &[
1890 "-c",
1891 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)""#,
1892 ],
1893 &[],
1894 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1895 &policy,
1896 &enforcement,
1897 )
1898 .expect("bash must start under the limits");
1899 println!("{}", result.stdout.trim());
1900
1901 assert_eq!(
1902 result.stdout.trim(),
1903 "5 6 64 64",
1904 "stderr was: {}",
1905 result.stderr
1906 );
1907 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1908 }
1909
1910 #[cfg(target_os = "linux")]
1914 #[test]
1915 fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
1916 const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
1917 if !crate::cgroup::cgroup_delegation_available() {
1918 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
1919 return;
1920 }
1921
1922 let temp = tempdir().unwrap();
1923 let limits = crate::resources::HostResourceLimits {
1924 cgroup_pids_max: 16,
1925 ..crate::resources::HostResourceLimits::default()
1926 };
1927 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
1928 .expect("this host delegates a cgroup scope")
1929 .scope;
1930 let policy = CapabilityPolicy {
1931 shell_allow: vec!["python3".to_string()],
1932 resources: limits,
1933 ..CapabilityPolicy::default()
1934 };
1935 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1936 enforcement.resource_limits = limits;
1937 let enforcement = enforcement.with_host_bounding(scope, None);
1938
1939 let script = "import os, resource, signal, time\n\
1940 children = []\n\
1941 for _ in range(4096):\n\
1942 \x20 try:\n\
1943 \x20 pid = os.fork()\n\
1944 \x20 except OSError:\n\
1945 \x20 break\n\
1946 \x20 if pid == 0:\n\
1947 \x20 time.sleep(30)\n\
1948 \x20 os._exit(0)\n\
1949 \x20 children.append(pid)\n\
1950 for pid in children:\n\
1951 \x20 os.kill(pid, signal.SIGKILL)\n\
1952 for pid in children:\n\
1953 \x20 os.waitpid(pid, 0)\n\
1954 soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
1955 print(len(children))\n\
1956 print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
1957 let result = execute_shell(
1958 "python3",
1959 &["-c", script],
1960 &[],
1961 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1962 &policy,
1963 &enforcement,
1964 )
1965 .expect("python3 must run");
1966
1967 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1968 let mut lines = result.stdout.lines();
1969 let reached: u64 = lines.next().unwrap().parse().unwrap();
1970 let soft = lines.next().unwrap();
1971 assert!(
1972 reached < 16,
1973 "pids.max is 16 and the python process holds one task, so at most 15 children can \
1974 be live; reached {reached}"
1975 );
1976 assert_eq!(
1977 result.resource_limit_hit,
1978 Some("cgroup_pids_max".to_string())
1979 );
1980 let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
1981 assert_eq!(
1982 soft,
1983 crate::resources::test_support::rendered_rlimit(own_soft),
1984 "a scoped spawn inherits RLIMIT_NPROC unchanged"
1985 );
1986 }
1987
1988 #[test]
1995 fn home_folder_is_home_and_the_work_folder_gains_nothing() {
1996 let accessible = tempdir().unwrap();
1997 let runtime = tempdir().unwrap();
1998 let enforcement = ShellEnforcement::environment_only()
1999 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2000 let home = enforcement.home_dir_path().unwrap().to_path_buf();
2001 let policy = CapabilityPolicy {
2002 shell_allow: vec!["bash".to_string()],
2003 ..CapabilityPolicy::default()
2004 };
2005
2006 let result = execute_shell(
2007 "bash",
2008 &[
2009 "-c",
2010 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && echo \"$HOME\" && pwd",
2011 ],
2012 &[],
2013 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
2014 &policy,
2015 &enforcement,
2016 )
2017 .expect("bash must run");
2018
2019 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
2020 let mut lines = result.stdout.lines();
2021 assert_eq!(lines.next(), Some("marker"));
2022 assert_eq!(lines.next(), Some(home.to_str().unwrap()));
2023 assert_eq!(
2024 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
2025 std::fs::canonicalize(accessible.path()).unwrap(),
2026 "the subprocess cwd is the work folder"
2027 );
2028 assert_eq!(fs::read_to_string(home.join("probe")).unwrap(), "marker\n");
2029 assert_eq!(
2030 fs::read_dir(accessible.path()).unwrap().count(),
2031 0,
2032 "the work folder gains nothing"
2033 );
2034 }
2035
2036 fn print_seq(workdir: &Path, enforcement: &ShellEnforcement, last: u32) -> ShellResult {
2039 let policy = CapabilityPolicy {
2040 shell_allow: vec!["bash".to_string()],
2041 ..CapabilityPolicy::default()
2042 };
2043 let command = format!("seq 1 {last}");
2044 execute_shell(
2045 "bash",
2046 &["-c", &command],
2047 &[],
2048 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
2049 &policy,
2050 enforcement,
2051 )
2052 .expect("bash must run")
2053 }
2054
2055 #[test]
2058 fn a_truncated_output_log_lands_in_the_records_directory() {
2059 let workdir = tempdir().unwrap();
2060 let runtime = tempdir().unwrap();
2061 let enforcement = ShellEnforcement::environment_only()
2062 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2063 let records_root = enforcement.records_root().unwrap().clone();
2064
2065 let result = print_seq(workdir.path(), &enforcement, 20_000);
2066
2067 assert!(result.truncated);
2068 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
2069 let path = result
2070 .full_output_path
2071 .expect("a truncated command names its log");
2072 let file_name = Path::new(&path).file_name().unwrap().to_str().unwrap();
2073 let ms = file_name
2074 .strip_prefix("shell-")
2075 .and_then(|rest| rest.strip_suffix(".log"))
2076 .unwrap_or_else(|| panic!("{path} is not logs/shell-<ms>.log"));
2077 assert!(ms.chars().all(|c| c.is_ascii_digit()), "{path}");
2078 assert_eq!(
2079 Path::new(&path),
2080 records_root.host_path("logs").join(file_name),
2081 );
2082 assert!(Path::new(&path).is_absolute(), "{path}");
2083
2084 let log = fs::read_to_string(&path).unwrap();
2085 let expected: String = (1..=20_000).map(|n| format!("{n}\n")).collect();
2086 let stdout = log
2087 .split_once("\nStdout:\n")
2088 .and_then(|(_, rest)| rest.split_once("\n\nStderr:\n"))
2089 .map(|(stdout, _)| stdout)
2090 .expect("the log keeps its Stdout: and Stderr: sections");
2091 assert_eq!(stdout, expected, "the log holds the full stdout");
2092 assert!(log.starts_with("Command: bash -c seq 1 20000\nExit code: 0\n"));
2093 assert!(
2094 log.ends_with("\n\nStderr:\n\n"),
2095 "the log keeps the empty Stderr: section"
2096 );
2097
2098 assert!(
2099 !workdir.path().join("logs").exists(),
2100 "nothing is written into the accessible workdir"
2101 );
2102 assert_eq!(fs::read_dir(workdir.path()).unwrap().count(), 0);
2103 }
2104
2105 #[test]
2108 fn with_no_records_directory_a_truncated_command_writes_no_log() {
2109 let runtime = tempdir().unwrap();
2110 let open = |sub: &str| {
2111 let path = runtime.path().join(sub);
2112 fs::create_dir_all(&path).unwrap();
2113 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
2114 };
2115 let roots_without_records = ShellEnforcement::environment_only().with_runtime_dir(
2116 crate::runtime_dir::RuntimeDirRoots::new(
2117 open(crate::runtime_dir::RUNTIME_SUBDIR),
2118 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
2119 open(crate::runtime_dir::HARNESS_SUBDIR),
2120 )
2121 .with_home(open(crate::records_dir::HOME_SUBDIR)),
2122 None,
2123 );
2124
2125 let enforcement = &roots_without_records;
2126 let workdir = tempdir().unwrap();
2127 let result = print_seq(workdir.path(), enforcement, 20_000);
2128 assert!(result.truncated);
2129 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
2130 assert_eq!(result.full_output_path, None);
2131 assert_eq!(
2132 fs::read_dir(workdir.path()).unwrap().count(),
2133 0,
2134 "nothing is written into the workdir"
2135 );
2136 assert!(!runtime.path().join("logs").exists());
2137 assert!(!runtime
2138 .path()
2139 .join(crate::runtime_dir::RUNTIME_SUBDIR)
2140 .join("logs")
2141 .exists());
2142 }
2143
2144 #[test]
2147 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
2148 let temp = tempdir().unwrap();
2149 let policy = CapabilityPolicy {
2150 shell_allow: vec!["bash".to_string()],
2151 ..CapabilityPolicy::default()
2152 };
2153
2154 let result = execute_shell(
2155 "bash",
2156 &["-c", "exit 3"],
2157 &[],
2158 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2159 &policy,
2160 &ShellEnforcement::environment_only().with_test_session_dirs(),
2161 )
2162 .expect("bash must run");
2163
2164 assert_eq!(result.exit_code, 3);
2165 assert_eq!(result.resource_limit_hit, None);
2166 }
2167
2168 #[test]
2169 fn execute_shell_blocks_binary_not_in_allowlist() {
2170 let policy = CapabilityPolicy::default();
2171 let error = execute_shell(
2172 "bash",
2173 &["-c", "echo hi"],
2174 &[],
2175 &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
2176 &policy,
2177 &ShellEnforcement::environment_only().with_test_session_dirs(),
2178 )
2179 .unwrap_err();
2180
2181 assert!(error.to_string().contains("capabilities.shell.allow"));
2182 }
2183
2184 #[test]
2185 fn execute_shell_returns_nonzero_exit_code_without_error() {
2186 let temp = tempdir().unwrap();
2187 let policy = CapabilityPolicy {
2188 shell_allow: vec!["bash".to_string()],
2189 ..CapabilityPolicy::default()
2190 };
2191
2192 let result = execute_shell(
2193 "bash",
2194 &["-c", "exit 42"],
2195 &[],
2196 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2197 &policy,
2198 &ShellEnforcement::environment_only().with_test_session_dirs(),
2199 )
2200 .unwrap();
2201 assert_eq!(result.exit_code, 42);
2202 }
2203
2204 #[test]
2208 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
2209 let temp = tempdir().unwrap();
2210 let policy = CapabilityPolicy {
2211 shell_allow: vec!["bash".to_string()],
2212 ..CapabilityPolicy::default()
2213 };
2214
2215 let result = execute_shell(
2216 "bash",
2217 &["-c", "exit 0"],
2218 &[],
2219 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2220 &policy,
2221 &ShellEnforcement::environment_only().with_test_session_dirs(),
2222 )
2223 .unwrap();
2224
2225 assert!(
2226 Path::new(&result.binary).is_absolute(),
2227 "a binary found on PATH must be reported as an absolute path, got {:?}",
2228 result.binary
2229 );
2230 assert!(
2231 result.binary.ends_with("bash"),
2232 "the reported path must still name bash, got {:?}",
2233 result.binary
2234 );
2235 }
2236
2237 #[test]
2243 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
2244 let temp = tempdir().unwrap();
2245 let tool = temp.path().join("local-tool");
2246 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
2247 let mut perms = fs::metadata(&tool).unwrap().permissions();
2248 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2249 fs::set_permissions(&tool, perms).unwrap();
2250
2251 let policy = CapabilityPolicy {
2252 shell_allow: vec!["./local-tool".to_string()],
2253 ..CapabilityPolicy::default()
2254 };
2255
2256 let result = execute_shell(
2257 "./local-tool",
2258 &[],
2259 &[],
2260 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2261 &policy,
2262 &ShellEnforcement::environment_only().with_test_session_dirs(),
2263 )
2264 .unwrap();
2265
2266 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
2267 assert_eq!(
2268 result.binary, "./local-tool",
2269 "an unresolvable name degrades to the bare invoked name, never an error"
2270 );
2271 }
2272
2273 #[test]
2274 fn build_shell_env_sets_home_to_the_home_folder() {
2275 let temp = tempdir().unwrap();
2276 let policy = CapabilityPolicy::default();
2277
2278 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2279
2280 let expected_home = temp.path().to_string_lossy().into_owned();
2281 assert_eq!(env.get("HOME"), Some(&expected_home));
2282 assert_eq!(env.get("USERPROFILE"), Some(&expected_home));
2283 }
2284
2285 #[test]
2286 fn no_home_folder_build_shell_env_refuses() {
2287 let error =
2288 build_shell_env(&CapabilityPolicy::default(), &[], None, None, None, None).unwrap_err();
2289 assert_eq!(error, NO_HOME_FOLDER_REFUSAL);
2290 assert!(error.contains("home folder"), "{error}");
2291 }
2292
2293 #[test]
2297 fn no_home_folder_execute_shell_refuses_and_creates_nothing() {
2298 let parent = tempdir().unwrap();
2299 let workdir = parent.path().join("work");
2300 fs::create_dir(&workdir).unwrap();
2301 let runtime = tempdir().unwrap();
2302 let roots = crate::sandbox::test_runtime_dir(runtime.path());
2303 let without_home = crate::runtime_dir::RuntimeDirRoots {
2304 home: None,
2305 ..roots
2306 };
2307 let policy = CapabilityPolicy {
2308 shell_allow: vec!["bash".to_string()],
2309 ..CapabilityPolicy::default()
2310 };
2311 for enforcement in [
2312 ShellEnforcement::environment_only(),
2313 ShellEnforcement::environment_only().with_runtime_dir(without_home.clone(), None),
2314 ] {
2315 let error = execute_shell(
2316 "bash",
2317 &["-c", "touch ran"],
2318 &[],
2319 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
2320 &policy,
2321 &enforcement,
2322 )
2323 .unwrap_err();
2324 assert!(error.to_string().contains("no home folder"), "{error}");
2325 }
2326 assert_eq!(fs::read_dir(&workdir).unwrap().count(), 0);
2327 assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1);
2328 }
2329
2330 #[test]
2331 fn execute_shell_reports_the_home_folder_not_real_host_home() {
2332 let temp = tempdir().unwrap();
2333 let runtime = tempdir().unwrap();
2334 let enforcement = ShellEnforcement::environment_only()
2335 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2336 let policy = CapabilityPolicy {
2337 shell_allow: vec!["bash".to_string()],
2338 ..CapabilityPolicy::default()
2339 };
2340
2341 let result = execute_shell(
2342 "bash",
2343 &["-c", "echo -n $HOME"],
2344 &[],
2345 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2346 &policy,
2347 &enforcement,
2348 )
2349 .unwrap();
2350
2351 let expected_home = runtime.path().join("records/home");
2352 assert_eq!(result.stdout, expected_home.to_string_lossy());
2353 if let Ok(real_home) = std::env::var("HOME") {
2354 assert_ne!(result.stdout, real_home);
2355 }
2356 }
2357
2358 #[test]
2359 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
2360 let temp = tempdir().unwrap();
2361 let policy = CapabilityPolicy::default();
2362 let overrides = vec![
2363 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
2364 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
2365 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
2366 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
2367 ("SAFE_VAR".to_string(), "kept".to_string()),
2368 ];
2369
2370 let env =
2371 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2372
2373 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
2374 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
2375 assert!(!env.contains_key("STRIPE_API_KEY"));
2376 assert!(!env.contains_key("GITHUB_TOKEN"));
2377 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
2378 }
2379
2380 #[test]
2381 fn declared_env_is_empty_without_declarations() {
2382 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
2383 let policy = CapabilityPolicy::default();
2384
2385 let env = build_declared_env(&policy);
2386
2387 assert!(env.is_empty());
2388 }
2389
2390 #[test]
2393 fn declared_and_shell_env_never_carry_the_formation_lifeline() {
2394 use crate::lifeline::FORMATION_LIFELINE_ENV;
2395 let _guard = crate::formation::FORMATION_ENV_LOCK
2396 .lock()
2397 .unwrap_or_else(|poisoned| poisoned.into_inner());
2398 let temp = tempdir().unwrap();
2399 std::env::set_var(FORMATION_LIFELINE_ENV, "7");
2400 let policy = CapabilityPolicy {
2401 env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
2402 shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
2403 ..CapabilityPolicy::default()
2404 };
2405 let declared = build_declared_env(&policy);
2406 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2407 std::env::remove_var(FORMATION_LIFELINE_ENV);
2408 assert!(
2409 !declared.contains_key(FORMATION_LIFELINE_ENV),
2410 "{declared:?}"
2411 );
2412 assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
2413 }
2414
2415 #[test]
2417 fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
2418 use crate::lifeline::SPAWNER_LIFELINE_ENV;
2419 let _guard = crate::formation::FORMATION_ENV_LOCK
2420 .lock()
2421 .unwrap_or_else(|poisoned| poisoned.into_inner());
2422 let temp = tempdir().unwrap();
2423 std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
2424 let policy = CapabilityPolicy {
2425 env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
2426 shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
2427 ..CapabilityPolicy::default()
2428 };
2429 let declared = build_declared_env(&policy);
2430 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2431 std::env::remove_var(SPAWNER_LIFELINE_ENV);
2432 assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
2433 assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
2434 }
2435
2436 #[test]
2437 fn declared_env_passes_through_declared_host_var() {
2438 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
2439 let policy = CapabilityPolicy {
2440 env_allow: vec![
2441 "MURMUR_TEST_WASI_ALLOWED".to_string(),
2442 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
2444 ],
2445 ..CapabilityPolicy::default()
2446 };
2447
2448 let env = build_declared_env(&policy);
2449
2450 assert_eq!(
2451 env.get("MURMUR_TEST_WASI_ALLOWED"),
2452 Some(&"host-value".to_string())
2453 );
2454 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
2455 }
2456
2457 #[test]
2458 fn declared_env_strips_credential_shaped_declarations() {
2459 std::env::set_var("GITHUB_TOKEN", "leaked-token");
2460 std::env::set_var("STRIPE_API_KEY", "leaked-key");
2461 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
2462 let policy = CapabilityPolicy {
2463 env_allow: vec![
2464 "GITHUB_TOKEN".to_string(),
2465 "STRIPE_API_KEY".to_string(),
2466 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
2467 ],
2468 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
2469 ..CapabilityPolicy::default()
2470 };
2471
2472 let env = build_declared_env(&policy);
2473
2474 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
2476 }
2477
2478 #[test]
2481 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
2482 std::env::set_var("PRIVATE_KEY", "host-private-key");
2483 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
2484 let policy = CapabilityPolicy {
2485 env_allow: vec![
2486 "PRIVATE_KEY".to_string(),
2487 "MURMUR_TEST_WASI_PLAIN".to_string(),
2488 ],
2489 ..CapabilityPolicy::default()
2490 };
2491
2492 let env = build_declared_env(&policy);
2493
2494 assert_eq!(
2495 env.get("PRIVATE_KEY"),
2496 Some(&"host-private-key".to_string())
2497 );
2498 assert_eq!(
2499 env.get("MURMUR_TEST_WASI_PLAIN"),
2500 Some(&"host-plain".to_string())
2501 );
2502 }
2503
2504 #[test]
2505 fn build_shell_env_home_override_cannot_survive() {
2506 let temp = tempdir().unwrap();
2507 let policy = CapabilityPolicy {
2508 shell_baseline_env: vec!["HOME".to_string()],
2509 ..CapabilityPolicy::default()
2510 };
2511 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
2512
2513 let env =
2514 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2515
2516 assert_eq!(
2517 env.get("HOME"),
2518 Some(&temp.path().to_string_lossy().into_owned())
2519 );
2520 }
2521
2522 #[test]
2523 fn build_shell_env_keeps_safe_baseline_vars() {
2524 let temp = tempdir().unwrap();
2525 let policy = CapabilityPolicy::default();
2526
2527 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
2528 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2529 std::env::remove_var("CARGO_HOME");
2530
2531 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
2532 }
2533
2534 #[test]
2537 fn build_shell_env_carries_the_hosts_account_names() {
2538 let temp = tempdir().unwrap();
2539 let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
2540 let _guard = ACCOUNT_ENV_LOCK
2541 .lock()
2542 .unwrap_or_else(|poisoned| poisoned.into_inner());
2543 std::env::set_var("USER", "murmur-test-user");
2544 std::env::set_var("LOGNAME", "murmur-test-login");
2545
2546 let env = build_shell_env(
2547 &CapabilityPolicy::default(),
2548 &[],
2549 Some(root.path()),
2550 None,
2551 None,
2552 None,
2553 )
2554 .unwrap();
2555 let stripped = build_shell_env(
2556 &CapabilityPolicy {
2557 shell_strip_env: vec!["LOGNAME".to_string()],
2558 ..CapabilityPolicy::default()
2559 },
2560 &[],
2561 Some(root.path()),
2562 None,
2563 None,
2564 None,
2565 )
2566 .unwrap();
2567
2568 assert_eq!(
2569 env.get("USER").map(String::as_str),
2570 Some("murmur-test-user")
2571 );
2572 assert_eq!(
2573 env.get("LOGNAME").map(String::as_str),
2574 Some("murmur-test-login")
2575 );
2576 assert_eq!(
2577 env.get("HOME"),
2578 Some(&temp.path().to_string_lossy().into_owned())
2579 );
2580 assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
2581 assert_eq!(
2582 stripped.get("USER").map(String::as_str),
2583 Some("murmur-test-user")
2584 );
2585 let guest = build_declared_env(&CapabilityPolicy::default());
2586 assert!(
2587 !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
2588 "a WASM guest is handed only what env.allow declares: {guest:?}"
2589 );
2590 }
2591
2592 #[test]
2593 fn host_account_with_both_names_set_reports_none_unset() {
2594 let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
2595 assert!(account.unset().is_empty());
2596 let mut env = BTreeMap::new();
2597 account.apply(&mut env);
2598 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2599 assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
2600 }
2601
2602 #[test]
2604 fn host_account_with_one_name_set_never_copies_it_to_the_other() {
2605 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2606 assert_eq!(account.unset(), ["LOGNAME".to_string()]);
2607 let mut env = BTreeMap::new();
2608 account.apply(&mut env);
2609 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2610 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2611 }
2612
2613 #[test]
2614 fn host_account_with_neither_name_set_reports_both_sorted() {
2615 let account = HostAccountEnv::from_pairs(&[]);
2616 assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
2617 let mut env = BTreeMap::new();
2618 account.apply(&mut env);
2619 assert!(env.is_empty(), "{env:?}");
2620 }
2621
2622 #[test]
2625 fn host_account_apply_replaces_a_stale_value() {
2626 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2627 let mut env = BTreeMap::from([
2628 ("USER".to_string(), "someone-else".to_string()),
2629 ("LOGNAME".to_string(), "someone-else".to_string()),
2630 ("PATH".to_string(), "/bin".to_string()),
2631 ]);
2632 account.apply(&mut env);
2633 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2634 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2635 assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
2636 }
2637
2638 #[test]
2639 fn build_shell_env_developer_declared_strip_env_still_composes() {
2640 let temp = tempdir().unwrap();
2641 let policy = CapabilityPolicy {
2642 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
2643 ..CapabilityPolicy::default()
2644 };
2645 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
2646
2647 let env =
2648 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2649
2650 assert!(!env.contains_key("MYCOMPANY_SECRET"));
2651 }
2652
2653 #[test]
2657 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
2658 let extra = vec!["*_SERVICE_SECRET".to_string()];
2659 let names = [
2660 "ANTHROPIC_API_KEY",
2661 "GITHUB_TOKEN",
2662 "GITHUB_TOKEN_2",
2663 "AWS_ACCESS_KEY_ID",
2664 "MY_AWS_KEY",
2665 "STRIPE_API_KEY",
2666 "KUBECONFIG",
2667 "MY_SERVICE_SECRET",
2668 "DATABASE_PASSWORD",
2669 "SLACK_BOT_TOKEN",
2670 "HOME",
2671 "TZ",
2672 ];
2673
2674 let mut env: BTreeMap<String, String> = names
2675 .iter()
2676 .map(|name| ((*name).to_string(), "value".to_string()))
2677 .collect();
2678 strip_credential_shaped_vars(&mut env, &extra);
2679
2680 for name in names {
2681 assert_eq!(
2682 credential_backstop_drops(name, &extra),
2683 !env.contains_key(name),
2684 "prediction and strip disagree about {name}"
2685 );
2686 assert_eq!(
2687 credential_backstop_match(name, &extra).is_some(),
2688 !env.contains_key(name),
2689 "match and strip disagree about {name}"
2690 );
2691 }
2692 }
2693
2694 #[test]
2695 fn credential_backstop_match_attributes_a_builtin_pattern() {
2696 assert_eq!(
2697 credential_backstop_match("AWS_REGION", &[]),
2698 Some(BackstopMatch {
2699 pattern: "AWS_*".to_string(),
2700 source: BackstopPatternSource::Builtin,
2701 })
2702 );
2703 assert_eq!(
2704 credential_backstop_match("GITHUB_TOKEN", &[]),
2705 Some(BackstopMatch {
2706 pattern: "GITHUB_TOKEN".to_string(),
2707 source: BackstopPatternSource::Builtin,
2708 })
2709 );
2710 }
2711
2712 #[test]
2713 fn credential_backstop_match_attributes_a_strip_env_pattern() {
2714 assert_eq!(
2715 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
2716 Some(BackstopMatch {
2717 pattern: "*_SERVICE_SECRET".to_string(),
2718 source: BackstopPatternSource::StripEnv,
2719 })
2720 );
2721 }
2722
2723 #[test]
2726 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
2727 assert_eq!(
2728 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
2729 Some(BackstopMatch {
2730 pattern: "*_API_KEY".to_string(),
2731 source: BackstopPatternSource::Builtin,
2732 })
2733 );
2734 }
2735
2736 #[test]
2737 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
2738 assert_eq!(credential_backstop_match("TZ", &[]), None);
2739 assert_eq!(
2740 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
2741 None
2742 );
2743 }
2744
2745 #[test]
2746 fn env_name_matches_pattern_trailing_wildcard() {
2747 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
2748 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
2749 }
2750
2751 #[test]
2752 fn env_name_matches_pattern_leading_wildcard() {
2753 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
2754 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
2755 }
2756
2757 #[test]
2758 fn env_name_matches_pattern_exact_match() {
2759 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
2760 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
2761 }
2762
2763 #[test]
2764 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
2765 let yaml = shell_tool_manifest_yaml("bash");
2766 serde_norway::from_str::<serde_norway::Value>(&yaml)
2767 .expect("bash manifest must be valid YAML");
2768 }
2769
2770 #[test]
2771 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
2772 let yaml = shell_tool_manifest_yaml("curl");
2773 serde_norway::from_str::<serde_norway::Value>(&yaml)
2774 .expect("curl manifest must be valid YAML — embedded quotes break YAML parsing");
2775 }
2776
2777 #[test]
2778 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
2779 let yaml = shell_tool_manifest_yaml("curl");
2780 let value: serde_norway::Value = serde_norway::from_str(&yaml).unwrap();
2781 assert_eq!(
2782 value.get("runtime").and_then(|v| v.as_str()),
2783 Some("tool"),
2784 "non-interpreter manifest must have runtime: tool so inventory picks it up"
2785 );
2786 }
2787
2788 #[test]
2791 fn a_direct_binary_description_states_the_argument_rule_and_nothing_else() {
2792 for binary in ["curl", "git", "jq", "python3"] {
2793 let yaml = shell_tool_manifest_yaml(binary);
2794 let value: serde_norway::Value = serde_norway::from_str(&yaml)
2795 .unwrap_or_else(|e| panic!("{binary} manifest must be valid YAML: {e}"));
2796 let description = value
2797 .get("description")
2798 .and_then(|v| v.as_str())
2799 .expect("the manifest has a description");
2800 assert_eq!(
2801 description,
2802 format!(
2803 "Run {binary} in the capsule workdir. The `command` field holds {binary}'s \
2804 arguments and not the binary name. It is split into arguments at whitespace; \
2805 single or double quotes group words, and a backslash escapes the next \
2806 character. No shell runs it, so nothing in it is expanded."
2807 )
2808 );
2809 assert!(!description.contains("example.com"), "{description}");
2810 assert!(!description.contains(" -s "), "{description}");
2811 assert_eq!(yaml, shell_tool_manifest_yaml(binary));
2812 }
2813 }
2814
2815 #[test]
2818 fn an_abandoned_command_demotes_before_its_grace() {
2819 an_abandoned_command_demotes_under(Some(Duration::from_secs(60)));
2820 }
2821
2822 #[test]
2824 fn an_abandoned_command_demotes_with_no_grace() {
2825 an_abandoned_command_demotes_under(None);
2826 }
2827
2828 fn an_abandoned_command_demotes_under(grace: Option<Duration>) {
2829 use std::sync::Arc;
2830
2831 use crate::{
2832 detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
2833 origin::{TaskOrigin, TaskProvenance},
2834 };
2835
2836 let temp = tempdir().unwrap();
2837 let runtime = tempdir().unwrap();
2838 let policy = CapabilityPolicy {
2839 shell_allow: vec!["bash".to_string()],
2840 ..CapabilityPolicy::default()
2841 };
2842 let (registry, _reports) = DetachedRegistry::new();
2843 let abandon = AbandonSignal::new();
2844 let raiser = {
2845 let abandon = abandon.clone();
2846 std::thread::spawn(move || {
2847 std::thread::sleep(Duration::from_millis(200));
2848 abandon.raise();
2849 Instant::now()
2850 })
2851 };
2852
2853 let started = Instant::now();
2854 let outcome = run_shell(
2855 "bash",
2856 &["-c", "sleep 5"],
2859 &[],
2860 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2861 &policy,
2862 &ShellEnforcement::environment_only()
2863 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2864 Some(DetachPolicy {
2865 grace,
2866 delivered: true,
2867 registry: Arc::clone(®istry),
2868 command: "sleep 5".to_string(),
2869 context_id: "ctx_abandon_test".to_string(),
2870 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2871 abandoned: Some(abandon),
2872 canceled: None,
2873 }),
2874 )
2875 .expect("a declared binary runs");
2876 let returned = Instant::now();
2877 let elapsed = returned - started;
2878 let raised = raiser.join().unwrap();
2879
2880 let ShellOutcome::Detached(info) = outcome else {
2881 panic!("an abandoned command must demote, got {outcome:?}");
2882 };
2883 assert!(
2884 elapsed < Duration::from_secs(2),
2885 "demotion waited {elapsed:?}, as if the command had to finish first"
2886 );
2887 assert!(
2888 returned.saturating_duration_since(raised) < Duration::from_secs(1),
2889 "demotion came {:?} after the abandon",
2890 returned.saturating_duration_since(raised)
2891 );
2892 assert_eq!(
2893 info.grace_ms,
2894 grace.map(|grace| u64::try_from(grace.as_millis()).unwrap())
2895 );
2896 assert!(
2897 registry
2898 .outstanding()
2899 .iter()
2900 .any(|work| work.work_id == info.work_id),
2901 "the demoted command is registered like one that outran its grace"
2902 );
2903 }
2904
2905 fn cancel_test_policy(
2907 registry: &std::sync::Arc<crate::detached::DetachedRegistry>,
2908 command: &str,
2909 canceled: crate::cancel::CancelSignal,
2910 grace: Option<Duration>,
2911 ) -> crate::detached::DetachPolicy {
2912 use crate::origin::{TaskOrigin, TaskProvenance};
2913 crate::detached::DetachPolicy {
2914 grace,
2915 delivered: true,
2916 registry: std::sync::Arc::clone(registry),
2917 command: command.to_string(),
2918 context_id: "ctx_cancel_test".to_string(),
2919 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2920 abandoned: None,
2921 canceled: Some(canceled),
2922 }
2923 }
2924
2925 #[test]
2928 fn a_canceled_task_demotes_its_foreground_command() {
2929 a_canceled_task_demotes_its_foreground_command_under(Some(Duration::from_secs(60)));
2930 }
2931
2932 #[test]
2934 fn a_canceled_task_demotes_its_foreground_command_with_no_grace() {
2935 a_canceled_task_demotes_its_foreground_command_under(None);
2936 }
2937
2938 fn a_canceled_task_demotes_its_foreground_command_under(grace: Option<Duration>) {
2939 use crate::{
2940 cancel::{CancelSignal, PHASE_SHELL},
2941 detached::{DetachedRegistry, KillRunningOnDrop},
2942 };
2943
2944 let temp = tempdir().unwrap();
2945 let runtime = tempdir().unwrap();
2946 let policy = CapabilityPolicy {
2947 shell_allow: vec!["bash".to_string()],
2948 ..CapabilityPolicy::default()
2949 };
2950 let (registry, _reports) = DetachedRegistry::new();
2951 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
2952 let cancel = CancelSignal::new();
2953 let raiser = {
2954 let cancel = cancel.clone();
2955 std::thread::spawn(move || {
2956 std::thread::sleep(Duration::from_millis(200));
2957 cancel.cancel();
2958 Instant::now()
2959 })
2960 };
2961
2962 let started = Instant::now();
2963 let outcome = run_shell(
2964 "bash",
2965 &["-c", "sleep 30"],
2966 &[],
2967 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2968 &policy,
2969 &ShellEnforcement::environment_only()
2970 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2971 Some(cancel_test_policy(
2972 ®istry,
2973 "sleep 30",
2974 cancel.clone(),
2975 grace,
2976 )),
2977 )
2978 .expect("a declared binary runs");
2979 let returned = Instant::now();
2980 let elapsed = returned - started;
2981 let raised = raiser.join().unwrap();
2982
2983 let ShellOutcome::Detached(info) = outcome else {
2984 panic!("a canceled task's command must demote, got {outcome:?}");
2985 };
2986 assert!(
2987 elapsed < Duration::from_secs(2),
2988 "demotion waited {elapsed:?}, as if the command had to finish first"
2989 );
2990 assert!(
2991 returned.saturating_duration_since(raised) < Duration::from_secs(1),
2992 "demotion came {:?} after the cancel",
2993 returned.saturating_duration_since(raised)
2994 );
2995 assert!(
2996 registry
2997 .outstanding()
2998 .iter()
2999 .any(|work| work.work_id == info.work_id),
3000 "the demoted command is registered, so the cancel residue names it"
3001 );
3002 assert_eq!(cancel.phase(), PHASE_SHELL);
3003 }
3004
3005 #[test]
3008 fn an_earlier_claim_keeps_the_phase_over_the_shell() {
3009 use crate::{
3010 cancel::{CancelSignal, PHASE_INFERENCE},
3011 detached::{DetachedRegistry, KillRunningOnDrop},
3012 };
3013
3014 let temp = tempdir().unwrap();
3015 let runtime = tempdir().unwrap();
3016 let policy = CapabilityPolicy {
3017 shell_allow: vec!["bash".to_string()],
3018 ..CapabilityPolicy::default()
3019 };
3020 let (registry, _reports) = DetachedRegistry::new();
3021 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
3022 let cancel = CancelSignal::new();
3023 cancel.note_phase(PHASE_INFERENCE);
3024 let raiser = {
3025 let cancel = cancel.clone();
3026 std::thread::spawn(move || {
3027 std::thread::sleep(Duration::from_millis(200));
3028 cancel.cancel();
3029 })
3030 };
3031
3032 let outcome = run_shell(
3033 "bash",
3034 &["-c", "sleep 30"],
3035 &[],
3036 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3037 &policy,
3038 &ShellEnforcement::environment_only()
3039 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3040 Some(cancel_test_policy(
3041 ®istry,
3042 "sleep 30",
3043 cancel.clone(),
3044 Some(Duration::from_secs(60)),
3045 )),
3046 )
3047 .expect("a declared binary runs");
3048 raiser.join().unwrap();
3049
3050 assert!(matches!(outcome, ShellOutcome::Detached(_)), "{outcome:?}");
3051 assert_eq!(cancel.phase(), PHASE_INFERENCE);
3052 }
3053
3054 #[test]
3056 fn a_canceled_task_starts_no_command() {
3057 a_canceled_task_starts_no_command_under(Some(Duration::from_secs(60)));
3058 }
3059
3060 #[test]
3062 fn a_canceled_task_starts_no_command_with_no_grace() {
3063 a_canceled_task_starts_no_command_under(None);
3064 }
3065
3066 fn a_canceled_task_starts_no_command_under(grace: Option<Duration>) {
3067 use crate::{cancel::CancelSignal, detached::DetachedRegistry};
3068
3069 let temp = tempdir().unwrap();
3070 let runtime = tempdir().unwrap();
3071 let policy = CapabilityPolicy {
3072 shell_allow: vec!["bash".to_string()],
3073 ..CapabilityPolicy::default()
3074 };
3075 let (registry, _reports) = DetachedRegistry::new();
3076 let cancel = CancelSignal::new();
3077 cancel.cancel();
3078
3079 let result = run_shell(
3080 "bash",
3081 &["-c", "touch started"],
3082 &[],
3083 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3084 &policy,
3085 &ShellEnforcement::environment_only()
3086 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3087 Some(cancel_test_policy(
3088 ®istry,
3089 "touch started",
3090 cancel.clone(),
3091 grace,
3092 )),
3093 );
3094
3095 match result {
3096 Err(ShellExecError::Failed(message)) => assert_eq!(message, CANCELED_BEFORE_SPAWN),
3097 other => panic!("a canceled task's call must be refused, got {other:?}"),
3098 }
3099 assert!(!temp.path().join("started").exists(), "nothing was spawned");
3100 assert!(registry.outstanding().is_empty(), "nothing was registered");
3101 }
3102
3103 fn run_until_stopped(
3106 workdir: &std::path::Path,
3107 runtime: &std::path::Path,
3108 script: &str,
3109 stop: ForegroundStop,
3110 ) -> ForegroundRun {
3111 let mut enforcement = ShellEnforcement::environment_only()
3112 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime), None);
3113 enforcement.resource_limits.max_processes = 1 << 20;
3117 run_shell_until_stopped(
3118 "bash",
3119 &["-c", script],
3120 &[],
3121 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
3122 &CapabilityPolicy {
3123 shell_allow: vec!["bash".to_string()],
3124 ..CapabilityPolicy::default()
3125 },
3126 &enforcement,
3127 stop,
3128 )
3129 .expect("a declared binary runs")
3130 }
3131
3132 const STOPPABLE_SCRIPT: &str = "sleep 300 & echo $! > sleeper.pid; echo started; sleep 300";
3133
3134 #[test]
3137 fn a_foreground_stop_kills_the_group_on_cancel() {
3138 let temp = tempdir().unwrap();
3139 let runtime = tempdir().unwrap();
3140 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3141 let cancel = crate::cancel::CancelSignal::new();
3142 let raiser = {
3143 let cancel = cancel.clone();
3144 let pid_file = temp.path().join("sleeper.pid");
3145 std::thread::spawn(move || {
3146 while !pid_file.exists() {
3147 std::thread::sleep(Duration::from_millis(10));
3148 }
3149 std::thread::sleep(Duration::from_millis(100));
3150 cancel.cancel();
3151 Instant::now()
3152 })
3153 };
3154
3155 let run = run_until_stopped(
3156 temp.path(),
3157 runtime.path(),
3158 STOPPABLE_SCRIPT,
3159 ForegroundStop {
3160 canceled: cancel,
3161 timeout: Duration::from_secs(60),
3162 },
3163 );
3164 let raised = raiser.join().unwrap();
3165
3166 assert!(
3167 raised.elapsed() < Duration::from_secs(1),
3168 "the call returned {:?} after the cancel",
3169 raised.elapsed()
3170 );
3171 assert_eq!(run.stopped, Some(ForegroundStopReason::Canceled));
3172 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
3173 assert_eq!(run.result.resource_limit_hit, None);
3174 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3175 assert!(
3176 process_dead_within(sleeper, Duration::from_secs(1)),
3177 "the background sleeper {sleeper} outlived the stop"
3178 );
3179 }
3180
3181 #[test]
3183 fn a_foreground_stop_kills_the_group_at_the_timeout() {
3184 let temp = tempdir().unwrap();
3185 let runtime = tempdir().unwrap();
3186 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3187
3188 let started = Instant::now();
3189 let run = run_until_stopped(
3190 temp.path(),
3191 runtime.path(),
3192 STOPPABLE_SCRIPT,
3193 ForegroundStop {
3194 canceled: crate::cancel::CancelSignal::new(),
3195 timeout: Duration::from_secs(1),
3196 },
3197 );
3198 let elapsed = started.elapsed();
3199
3200 assert!(
3201 elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(2),
3202 "the call returned after {elapsed:?} for a 1 s timeout"
3203 );
3204 assert_eq!(run.stopped, Some(ForegroundStopReason::TimedOut));
3205 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
3206 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3207 assert!(
3208 process_dead_within(sleeper, Duration::from_secs(1)),
3209 "the background sleeper {sleeper} outlived the timeout"
3210 );
3211 }
3212
3213 #[test]
3215 fn a_foreground_stop_leaves_a_finished_command_alone() {
3216 let temp = tempdir().unwrap();
3217 let runtime = tempdir().unwrap();
3218 let run = run_until_stopped(
3219 temp.path(),
3220 runtime.path(),
3221 "echo out; echo err >&2; exit 3",
3222 ForegroundStop {
3223 canceled: crate::cancel::CancelSignal::new(),
3224 timeout: Duration::from_secs(60),
3225 },
3226 );
3227 assert_eq!(run.stopped, None);
3228 assert_eq!(run.result.exit_code, 3);
3229 assert_eq!(run.result.stdout, "out\n");
3230 assert_eq!(run.result.stderr, "err\n");
3231 }
3232
3233 fn returns_within_30_s<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
3235 let (sender, receiver) = std::sync::mpsc::channel();
3236 std::thread::spawn(move || {
3237 let _ = sender.send(run());
3238 });
3239 receiver
3240 .recv_timeout(Duration::from_secs(30))
3241 .unwrap_or_else(|_| panic!("the shell call did not return within 30 s"))
3242 }
3243
3244 #[test]
3247 fn a_background_holder_does_not_hold_a_graced_call() {
3248 use std::sync::Arc;
3249
3250 use crate::{
3251 detached::{DetachPolicy, DetachedRegistry},
3252 origin::{TaskOrigin, TaskProvenance},
3253 };
3254
3255 let temp = tempdir().unwrap();
3256 let runtime = tempdir().unwrap();
3257 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3258 let workdir = temp.path().to_path_buf();
3259 let runtime_base = runtime.path().to_path_buf();
3260 let script = "sleep 300 & echo $! > sleeper.pid; echo started";
3261
3262 let started = Instant::now();
3263 let outcome = returns_within_30_s(move || {
3264 let (registry, _reports) = DetachedRegistry::new();
3265 run_shell(
3266 "bash",
3267 &["-c", script],
3268 &[],
3269 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3270 &CapabilityPolicy {
3271 shell_allow: vec!["bash".to_string()],
3272 ..CapabilityPolicy::default()
3273 },
3274 &ShellEnforcement::environment_only()
3275 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3276 Some(DetachPolicy {
3277 grace: Some(Duration::from_secs(5)),
3278 delivered: true,
3279 registry: Arc::clone(®istry),
3280 command: script.to_string(),
3281 context_id: "ctx_holder_test".to_string(),
3282 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3283 abandoned: None,
3284 canceled: None,
3285 }),
3286 )
3287 .expect("a declared binary runs")
3288 });
3289 let elapsed = started.elapsed();
3290
3291 let ShellOutcome::Finished(result) = outcome else {
3292 panic!("a command that exits at once must finish, got {outcome:?}");
3293 };
3294 assert!(
3295 elapsed < Duration::from_secs(5),
3296 "the call took {elapsed:?}, past its 5 s grace"
3297 );
3298 assert!(result.stdout.contains("started"), "{result:?}");
3299 assert_eq!(result.exit_code, 0, "{result:?}");
3300 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3301 assert!(
3302 process_dead_within(sleeper, Duration::from_secs(2)),
3303 "the background sleeper {sleeper} is still running"
3304 );
3305 }
3306
3307 #[test]
3310 fn a_background_holder_does_not_hold_an_ungraced_call() {
3311 let temp = tempdir().unwrap();
3312 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3313 let workdir = temp.path().to_path_buf();
3314
3315 let started = Instant::now();
3316 let result = returns_within_30_s(move || {
3317 execute_shell(
3318 "bash",
3319 &["-c", "sleep 300 & echo $! > sleeper.pid; echo started"],
3320 &[],
3321 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3322 &CapabilityPolicy {
3323 shell_allow: vec!["bash".to_string()],
3324 ..CapabilityPolicy::default()
3325 },
3326 &ShellEnforcement::environment_only().with_test_session_dirs(),
3327 )
3328 .expect("a declared binary runs")
3329 });
3330 let elapsed = started.elapsed();
3331
3332 assert!(
3333 elapsed < Duration::from_secs(10),
3334 "the call took {elapsed:?}"
3335 );
3336 assert!(result.stdout.contains("started"), "{result:?}");
3337 assert_eq!(result.exit_code, 0, "{result:?}");
3338 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3339 assert!(
3340 process_dead_within(sleeper, Duration::from_secs(2)),
3341 "the background sleeper {sleeper} is still running"
3342 );
3343 assert_eq!(result.output_holders, OutputHolders::Killed);
3344 }
3345
3346 #[test]
3349 fn output_written_just_after_exit_is_kept() {
3350 use std::sync::Arc;
3351
3352 use crate::{
3353 detached::{DetachPolicy, DetachedRegistry},
3354 origin::{TaskOrigin, TaskProvenance},
3355 };
3356
3357 let temp = tempdir().unwrap();
3358 let runtime = tempdir().unwrap();
3359 let (registry, _reports) = DetachedRegistry::new();
3360 let script = "(sleep 0.3; echo late) & echo early";
3361
3362 let outcome = run_shell(
3363 "bash",
3364 &["-c", script],
3365 &[],
3366 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3367 &CapabilityPolicy {
3368 shell_allow: vec!["bash".to_string()],
3369 ..CapabilityPolicy::default()
3370 },
3371 &ShellEnforcement::environment_only()
3372 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3373 Some(DetachPolicy {
3374 grace: Some(Duration::from_secs(5)),
3375 delivered: true,
3376 registry: Arc::clone(®istry),
3377 command: script.to_string(),
3378 context_id: "ctx_late_output_test".to_string(),
3379 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3380 abandoned: None,
3381 canceled: None,
3382 }),
3383 )
3384 .expect("a declared binary runs");
3385
3386 let ShellOutcome::Finished(result) = outcome else {
3387 panic!("a command that exits at once must finish, got {outcome:?}");
3388 };
3389 assert!(result.stdout.contains("early"), "{result:?}");
3390 assert!(result.stdout.contains("late"), "{result:?}");
3391 assert_eq!(result.output_holders, OutputHolders::Closed);
3392 assert!(OutputHolders::Closed.note().is_none());
3393 }
3394
3395 #[cfg(target_os = "linux")]
3398 #[test]
3399 fn a_holder_outside_the_process_group_is_reported_not_waited_for() {
3400 use std::sync::Arc;
3401
3402 use crate::{
3403 detached::{DetachPolicy, DetachedRegistry},
3404 origin::{TaskOrigin, TaskProvenance},
3405 };
3406
3407 let temp = tempdir().unwrap();
3408 let runtime = tempdir().unwrap();
3409 let pid_file = temp.path().join("escaped.pid");
3410 let escaped_guard = KillPidFileOnDrop(pid_file.clone());
3411 let workdir = temp.path().to_path_buf();
3412 let runtime_base = runtime.path().to_path_buf();
3413 let script = "setsid sleep 300 & echo $! > escaped.pid; echo started";
3414
3415 let started = Instant::now();
3416 let outcome = returns_within_30_s(move || {
3417 let (registry, _reports) = DetachedRegistry::new();
3418 run_shell(
3419 "bash",
3420 &["-c", script],
3421 &[],
3422 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3423 &CapabilityPolicy {
3424 shell_allow: vec!["bash".to_string()],
3425 ..CapabilityPolicy::default()
3426 },
3427 &ShellEnforcement::environment_only()
3428 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3429 Some(DetachPolicy {
3430 grace: Some(Duration::from_secs(5)),
3431 delivered: true,
3432 registry: Arc::clone(®istry),
3433 command: script.to_string(),
3434 context_id: "ctx_escaped_test".to_string(),
3435 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3436 abandoned: None,
3437 canceled: None,
3438 }),
3439 )
3440 .expect("a declared binary runs")
3441 });
3442 let elapsed = started.elapsed();
3443
3444 let ShellOutcome::Finished(result) = outcome else {
3445 panic!("a command that exits at once must finish, got {outcome:?}");
3446 };
3447 let bound = Duration::from_secs(5) + LEFTOVER_KILL_WAIT + Duration::from_secs(2);
3448 assert!(elapsed < bound, "the call took {elapsed:?}, past {bound:?}");
3449 assert_eq!(result.stdout.trim(), "started", "{result:?}");
3450 assert_eq!(result.output_holders, OutputHolders::Escaped);
3451 let escaped = read_pid(&pid_file);
3452 assert!(
3453 !process_dead_within(escaped, Duration::ZERO),
3454 "a holder outside the process group is not the call's to kill"
3455 );
3456 drop(escaped_guard);
3457 assert!(process_dead_within(escaped, Duration::from_secs(2)));
3458 }
3459
3460 #[test]
3461 fn a_shared_window_attributes_no_cgroup_delta() {
3462 assert_eq!(
3463 attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
3464 Some("cgroup_memory_bytes"),
3465 "a call alone in the scope owns its delta"
3466 );
3467 assert_eq!(
3468 attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
3469 None,
3470 "a delta from a shared window could be a sibling's"
3471 );
3472 assert_eq!(
3473 attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
3474 .as_deref(),
3475 Some("cpu_seconds"),
3476 "the signal is per-process evidence and needs no exclusive window"
3477 );
3478 assert_eq!(attribute_resource_limit(None, None, true), None);
3479 }
3480
3481 #[test]
3485 fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
3486 let workdir = tempfile::tempdir().unwrap();
3487 let root = WorkdirRoot::open(workdir.path()).unwrap();
3488 std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
3489 let policy = CapabilityPolicy {
3490 shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3491 env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3492 ..CapabilityPolicy::default()
3493 };
3494 let overrides = [(
3495 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3496 "/override".to_string(),
3497 )];
3498
3499 let env = build_shell_env(
3500 &policy,
3501 &overrides,
3502 Some(root.path()),
3503 Some(std::path::Path::new("/runtime/dir")),
3504 None,
3505 None,
3506 )
3507 .unwrap();
3508 assert_eq!(
3509 env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
3510 .map(String::as_str),
3511 Some("/runtime/dir")
3512 );
3513
3514 let without =
3515 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3516 assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3517 assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3518 }
3519
3520 #[test]
3524 fn build_unconfined_env_inherits_everything_but_the_lineage_names() {
3525 let get = |env: &BTreeMap<OsString, OsString>, name: &str| {
3526 env.get(std::ffi::OsStr::new(name))
3527 .map(|value| value.to_string_lossy().into_owned())
3528 };
3529 let removed: Vec<&str> = LINEAGE_ENV
3530 .iter()
3531 .copied()
3532 .chain([
3533 crate::formation::FORMATION_PEERS_ENV,
3534 crate::formation_credentials::FORMATION_CHANNEL_ENV,
3535 crate::lifeline::FORMATION_LIFELINE_ENV,
3536 ARTIFACT_CONFIG_ENV,
3537 crate::state_store::STATE_DIR_ENV,
3538 ])
3539 .collect();
3540 let host: BTreeMap<OsString, OsString> = removed
3543 .iter()
3544 .map(|name| (*name, "host-value"))
3545 .chain([
3546 ("GITHUB_TOKEN", "unconfined-sentinel"),
3547 ("MURMUR_TEST_UNCONFINED", "present"),
3548 ("MURMUR_TEST_STRIPPED", "gone"),
3549 ("HOME", "/home/operator"),
3550 ("TMPDIR", "/var/tmp/operator"),
3551 (crate::runtime_dir::RUNTIME_DIR_ENV, "/host/runtime"),
3552 (crate::records_dir::RECORDS_DIR_ENV, "/host/records"),
3553 ])
3554 .map(|(name, value)| (OsString::from(name), OsString::from(value)))
3555 .collect();
3556 let policy = CapabilityPolicy {
3557 shell_strip_env: vec!["MURMUR_TEST_STRIP*".to_string()],
3558 ..CapabilityPolicy::default()
3559 };
3560 let overrides = [
3561 (
3562 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3563 "/override".to_string(),
3564 ),
3565 (
3566 crate::delegation::SPAWNER_ENV.to_string(),
3567 "override".to_string(),
3568 ),
3569 ("GUEST_SET".to_string(), "yes".to_string()),
3570 ];
3571
3572 let env = build_unconfined_env(
3573 &policy,
3574 host.clone(),
3575 &overrides,
3576 Some(std::path::Path::new("/session/runtime")),
3577 Some(std::path::Path::new("/session/records")),
3578 );
3579 for name in &removed {
3580 assert_eq!(
3581 get(&env, name),
3582 None,
3583 "{name} reached an unconfined command"
3584 );
3585 }
3586 assert_eq!(
3587 get(&env, "GITHUB_TOKEN").as_deref(),
3588 Some("unconfined-sentinel")
3589 );
3590 assert_eq!(
3591 get(&env, "MURMUR_TEST_UNCONFINED").as_deref(),
3592 Some("present")
3593 );
3594 assert_eq!(get(&env, "MURMUR_TEST_STRIPPED"), None);
3595 assert_eq!(get(&env, "HOME").as_deref(), Some("/home/operator"));
3596 assert_eq!(get(&env, "TMPDIR").as_deref(), Some("/var/tmp/operator"));
3597 assert_eq!(get(&env, "GUEST_SET").as_deref(), Some("yes"));
3598 assert_eq!(
3599 get(&env, crate::runtime_dir::RUNTIME_DIR_ENV).as_deref(),
3600 Some("/session/runtime")
3601 );
3602 assert_eq!(
3603 get(&env, crate::records_dir::RECORDS_DIR_ENV).as_deref(),
3604 Some("/session/records")
3605 );
3606 let without = build_unconfined_env(&policy, host, &overrides, None, None);
3608 assert_eq!(get(&without, crate::runtime_dir::RUNTIME_DIR_ENV), None);
3609 assert_eq!(get(&without, crate::records_dir::RECORDS_DIR_ENV), None);
3610 }
3611
3612 #[test]
3616 fn subprocess_env_follows_the_enforcement_tier() {
3617 let policy = CapabilityPolicy::default();
3618 let contained =
3619 crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs();
3620 let unconfined = crate::sandbox::ShellEnforcement {
3621 tier: crate::sandbox::EnforcementTier::Unconfined,
3622 ..contained.clone()
3623 };
3624 let session_home = contained.home_dir_path().unwrap().as_os_str().to_owned();
3625 let home = std::ffi::OsStr::new("HOME");
3626 assert_eq!(
3627 subprocess_env(&policy, &[], &contained).unwrap().get(home),
3628 Some(&session_home)
3629 );
3630 let unconfined_home = subprocess_env(&policy, &[], &unconfined)
3631 .unwrap()
3632 .get(home)
3633 .cloned();
3634 assert_eq!(unconfined_home, std::env::var_os("HOME"));
3635 assert_ne!(unconfined_home, Some(session_home));
3636 }
3637
3638 #[test]
3642 fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
3643 let workdir = tempfile::tempdir().unwrap();
3644 let root = WorkdirRoot::open(workdir.path()).unwrap();
3645 std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
3646 let policy = CapabilityPolicy {
3647 shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3648 env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3649 ..CapabilityPolicy::default()
3650 };
3651 let overrides = [(
3652 crate::records_dir::RECORDS_DIR_ENV.to_string(),
3653 "/override".to_string(),
3654 )];
3655
3656 let env = build_shell_env(
3657 &policy,
3658 &overrides,
3659 Some(root.path()),
3660 None,
3661 Some(std::path::Path::new("/records/dir")),
3662 None,
3663 )
3664 .unwrap();
3665 assert_eq!(
3666 env.get(crate::records_dir::RECORDS_DIR_ENV)
3667 .map(String::as_str),
3668 Some("/records/dir")
3669 );
3670
3671 let without =
3672 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3673 assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
3674 assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
3675 }
3676
3677 #[test]
3681 fn build_shell_env_drops_the_native_tool_settings_names_whatever_else_says() {
3682 let workdir = tempfile::tempdir().unwrap();
3683 let root = WorkdirRoot::open(workdir.path()).unwrap();
3684 let names = [ARTIFACT_CONFIG_ENV, crate::state_store::STATE_DIR_ENV];
3685 for name in names {
3686 std::env::set_var(name, "/host/value");
3687 }
3688 let policy = CapabilityPolicy {
3689 shell_baseline_env: names.iter().map(|name| name.to_string()).collect(),
3690 env_allow: names.iter().map(|name| name.to_string()).collect(),
3691 ..CapabilityPolicy::default()
3692 };
3693 let overrides: Vec<(String, String)> = names
3694 .iter()
3695 .map(|name| (name.to_string(), "/override".to_string()))
3696 .collect();
3697
3698 let env =
3699 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3700 let declared = build_declared_env(&policy);
3701 for name in names {
3702 std::env::remove_var(name);
3703 }
3704 for name in names {
3705 assert!(!env.contains_key(name), "{name}: {env:?}");
3706 assert!(!declared.contains_key(name), "{name}: {declared:?}");
3707 }
3708 }
3709
3710 #[test]
3714 fn build_shell_env_owns_tmpdir_tmp_and_temp() {
3715 use crate::temp_dir::{NotMappedReason, TempMapping, TEMP_DIR_ENV};
3716
3717 let workdir = tempfile::tempdir().unwrap();
3718 let root = WorkdirRoot::open(workdir.path()).unwrap();
3719 let base = tempfile::tempdir().unwrap();
3720 let roots = crate::sandbox::test_runtime_dir(base.path());
3721 let folder = roots.temp.as_ref().unwrap().path().to_path_buf();
3722 let host_tmp = std::env::temp_dir().join("murmur-host-tmp");
3725 std::fs::create_dir_all(&host_tmp).unwrap();
3726 let saved: Vec<_> = TEMP_DIR_ENV
3727 .iter()
3728 .map(|name| (*name, std::env::var_os(name)))
3729 .collect();
3730 for name in TEMP_DIR_ENV {
3731 std::env::set_var(name, &host_tmp);
3732 }
3733 let policy = CapabilityPolicy {
3734 shell_baseline_env: TEMP_DIR_ENV.iter().map(|name| name.to_string()).collect(),
3735 ..CapabilityPolicy::default()
3736 };
3737 let overrides: Vec<(String, String)> = TEMP_DIR_ENV
3738 .iter()
3739 .map(|name| (name.to_string(), "/override".to_string()))
3740 .collect();
3741
3742 let held = |tier, mapping| {
3743 crate::sandbox::ShellEnforcement {
3744 tier,
3745 ..crate::sandbox::ShellEnforcement::environment_only()
3746 }
3747 .with_runtime_dir(roots.clone(), None)
3748 .with_temp_mapping(mapping)
3749 };
3750 let folder_text = folder.to_string_lossy().into_owned();
3751 let cases = [
3752 (
3753 held(
3754 crate::sandbox::EnforcementTier::KernelFull,
3755 TempMapping::MountNamespace,
3756 ),
3757 Some("/tmp".to_string()),
3758 ),
3759 (
3760 held(
3761 crate::sandbox::EnforcementTier::KernelSealed,
3762 TempMapping::ComposedRoot,
3763 ),
3764 Some("/tmp".to_string()),
3765 ),
3766 (
3767 held(
3768 crate::sandbox::EnforcementTier::KernelFull,
3769 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
3770 ),
3771 Some(folder_text.clone()),
3772 ),
3773 (
3774 held(
3775 crate::sandbox::EnforcementTier::KernelSeccompOnly,
3776 TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
3777 ),
3778 Some(folder_text.clone()),
3779 ),
3780 (
3781 held(
3782 crate::sandbox::EnforcementTier::KernelFull,
3783 TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
3784 ),
3785 Some(folder_text.clone()),
3786 ),
3787 (
3788 held(
3789 crate::sandbox::EnforcementTier::EnvironmentOnly,
3790 TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
3791 ),
3792 Some(folder_text.clone()),
3793 ),
3794 (crate::sandbox::ShellEnforcement::environment_only(), None),
3795 ];
3796 for (enforcement, expected) in cases {
3797 assert_eq!(
3798 enforcement.tmpdir_value(),
3799 expected,
3800 "{:?}",
3801 enforcement.tier
3802 );
3803 let env = build_shell_env(
3804 &policy,
3805 &overrides,
3806 Some(root.path()),
3807 None,
3808 None,
3809 enforcement.tmpdir_value().as_deref(),
3810 )
3811 .unwrap();
3812 for name in TEMP_DIR_ENV {
3813 assert_eq!(
3814 env.get(name),
3815 expected.as_ref(),
3816 "{name} on {:?}",
3817 enforcement.tier
3818 );
3819 }
3820 }
3821 for (name, value) in saved {
3822 match value {
3823 Some(value) => std::env::set_var(name, value),
3824 None => std::env::remove_var(name),
3825 }
3826 }
3827 }
3828
3829 #[test]
3831 fn the_env_baseline_names_no_temp_variable() {
3832 for name in crate::temp_dir::TEMP_DIR_ENV {
3833 assert!(!DEFAULT_ENV_BASELINE.contains(&name), "{name}");
3834 }
3835 }
3836}
3837
3838#[cfg(test)]
3840pub(crate) mod test_support {
3841 use std::path::{Path, PathBuf};
3842 use std::time::{Duration, Instant};
3843
3844 pub(crate) fn read_pid(pid_file: &Path) -> u32 {
3846 std::fs::read_to_string(pid_file)
3847 .expect("the command wrote its pid file")
3848 .trim()
3849 .parse()
3850 .expect("the pid file holds a pid")
3851 }
3852
3853 pub(crate) fn process_dead_within(pid: u32, timeout: Duration) -> bool {
3856 let deadline = Instant::now() + timeout;
3857 loop {
3858 if !crate::running::pid_is_alive(pid) {
3859 return true;
3860 }
3861 if Instant::now() >= deadline {
3862 return false;
3863 }
3864 std::thread::sleep(Duration::from_millis(20));
3865 }
3866 }
3867
3868 pub(crate) struct KillPidFileOnDrop(pub(crate) PathBuf);
3872
3873 impl Drop for KillPidFileOnDrop {
3874 fn drop(&mut self) {
3875 let Some(pid) = std::fs::read_to_string(&self.0)
3876 .ok()
3877 .and_then(|text| text.trim().parse::<libc::pid_t>().ok())
3878 .filter(|pid| *pid > 0)
3879 else {
3880 return;
3881 };
3882 if !process_dead_within(pid.unsigned_abs(), Duration::ZERO) {
3883 #[allow(unsafe_code)]
3886 unsafe {
3887 libc::kill(pid, libc::SIGKILL);
3888 }
3889 }
3890 }
3891 }
3892}