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\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
477pub(crate) fn check_shell_allow(policy: &CapabilityPolicy, binary: &str) -> Result<(), String> {
480 if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
481 return Err(format!(
482 "binary '{binary}' is not in capabilities.shell.allow"
483 ));
484 }
485 Ok(())
486}
487
488pub(crate) enum ContainedSpawnError {
490 WorkdirBudget(String),
492 Exec(ShellExecError),
494}
495
496impl From<ContainedSpawnError> for ShellExecError {
497 fn from(error: ContainedSpawnError) -> Self {
498 match error {
499 ContainedSpawnError::WorkdirBudget(message) => Self::Failed(message),
500 ContainedSpawnError::Exec(error) => error,
501 }
502 }
503}
504
505pub(crate) struct ContainedChild {
508 pub(crate) child: std::process::Child,
509 pub(crate) pgid: i32,
511 pub(crate) resolved_binary: String,
513 pub(crate) started: Instant,
514 pub(crate) cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
515 pub(crate) cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
516}
517
518#[allow(clippy::too_many_arguments)]
527pub(crate) fn spawn_contained(
528 binary: &str,
529 args: &[&str],
530 env_overrides: &[(String, String)],
531 accessible_workdir: &WorkdirRoot,
532 policy: &CapabilityPolicy,
533 enforcement: &crate::sandbox::ShellEnforcement,
534 stdin: Stdio,
535 subject: &str,
536) -> Result<ContainedChild, ContainedSpawnError> {
537 use std::os::unix::process::CommandExt as _;
538
539 enforcement
542 .check_workdir_budget()
543 .map_err(ContainedSpawnError::WorkdirBudget)?;
544
545 let env = subprocess_env(policy, env_overrides, enforcement)
546 .map_err(|error| ContainedSpawnError::Exec(ShellExecError::Failed(error)))?;
547
548 let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
551
552 let started = Instant::now();
553 let mut command = Command::new(binary);
554 command
555 .args(args)
556 .current_dir(accessible_workdir.path())
557 .env_clear()
558 .envs(env)
559 .stdin(stdin)
563 .stdout(Stdio::piped())
564 .stderr(Stdio::piped())
565 .process_group(0);
568
569 let supervisor =
574 crate::sandbox::prepare_enforcement(&mut command, enforcement, accessible_workdir.path())
575 .map_err(|error| ContainedSpawnError::Exec(ShellExecError::Failed(error)))?;
576
577 let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
581 let cgroup_counters_before = enforcement
584 .cgroup_scope
585 .as_ref()
586 .map(|scope| scope.event_counters());
587
588 let child = match command.spawn() {
589 Ok(child) => child,
590 Err(error) => {
591 return Err(ContainedSpawnError::Exec(enforced_spawn_failure(
592 command,
593 &supervisor,
594 subject,
595 error.to_string(),
596 )))
597 }
598 };
599 supervisor.hand_off_bind_listener(enforcement);
602 let pgid = child.id() as i32;
604 SPAWNED_GROUP.with(|slot| {
605 if let Some(sink) = slot.borrow().as_ref() {
606 sink.store(pgid, std::sync::atomic::Ordering::SeqCst);
607 }
608 });
609
610 Ok(ContainedChild {
611 child,
612 pgid,
613 resolved_binary,
614 started,
615 cgroup_occupancy,
616 cgroup_counters_before,
617 })
618}
619
620fn spawn_and_wait(
624 binary: &str,
625 args: &[&str],
626 env_overrides: &[(String, String)],
627 accessible_workdir: &WorkdirRoot,
628 policy: &CapabilityPolicy,
629 enforcement: &crate::sandbox::ShellEnforcement,
630 wait: Wait,
631) -> Result<(ShellOutcome, Option<ForegroundStopReason>), ShellExecError> {
632 check_shell_allow(policy, binary).map_err(ShellExecError::Failed)?;
633
634 if let Wait::Detach(DetachPolicy {
637 canceled: Some(canceled),
638 ..
639 }) = &wait
640 {
641 if canceled.is_canceled() {
642 return Err(ShellExecError::Failed(CANCELED_BEFORE_SPAWN.to_string()));
643 }
644 }
645
646 let ContainedChild {
647 mut child,
648 pgid,
649 resolved_binary,
650 started,
651 cgroup_occupancy,
652 cgroup_counters_before,
653 } = spawn_contained(
654 binary,
655 args,
656 env_overrides,
657 accessible_workdir,
658 policy,
659 enforcement,
660 Stdio::null(),
661 "shell",
662 )?;
663
664 let readers = OutputReaders::spawn(&mut child);
668
669 let records_root = enforcement.records_root();
672 let wait = match wait {
673 Wait::Detach(_) if records_root.is_none() => Wait::Blocking,
674 wait => wait,
675 };
676
677 let mut stopped = None;
678 let (status, drain_deadline) = match wait {
679 Wait::Blocking => {
680 let status = child.wait().map_err(|error| error.to_string())?;
681 (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT)
682 }
683 Wait::Stop(stop) => {
684 let deadline = started + stop.timeout;
685 loop {
686 if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
687 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
688 }
689 let remaining = deadline.saturating_duration_since(Instant::now());
690 let reason = if stop.canceled.is_canceled() {
691 Some(ForegroundStopReason::Canceled)
692 } else if remaining.is_zero() {
693 Some(ForegroundStopReason::TimedOut)
694 } else {
695 None
696 };
697 if let Some(reason) = reason {
698 crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
700 let status = child.wait().map_err(|error| error.to_string())?;
701 stopped = Some(reason);
702 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
703 }
704 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
705 }
706 }
707 Wait::Detach(policy) => {
708 let deadline = policy.grace.map(|grace| started + grace);
710 loop {
711 match child.try_wait().map_err(|error| error.to_string())? {
712 Some(status) => {
713 let drain = Instant::now() + PIPE_DRAIN_AFTER_EXIT;
714 break (
715 status,
716 deadline.map_or(drain, |deadline| drain.min(deadline)),
717 );
718 }
719 None => {
720 let remaining = deadline
721 .map(|deadline| deadline.saturating_duration_since(Instant::now()));
722 let abandoned = policy
725 .abandoned
726 .as_ref()
727 .is_some_and(crate::detached::AbandonSignal::is_raised);
728 let canceled = policy
731 .canceled
732 .as_ref()
733 .filter(|signal| signal.is_canceled());
734 if let Some(signal) = canceled {
735 signal.note_phase(crate::cancel::PHASE_SHELL);
736 }
737 if remaining.is_some_and(|remaining| remaining.is_zero())
738 || abandoned
739 || canceled.is_some()
740 {
741 let records_root = records_root
743 .expect("a detach policy is kept only with a records root")
744 .clone();
745 let detached = demote(
746 child,
747 readers,
748 DemotionContext {
749 policy,
750 records_root,
751 invoked_binary: binary.to_string(),
752 resolved_binary,
753 args: args.iter().map(|arg| (*arg).to_string()).collect(),
754 started,
755 pgid,
756 cgroup_scope: enforcement.cgroup_scope.clone(),
757 cgroup_counters_before,
758 cgroup_occupancy,
759 },
760 );
761 return Ok((ShellOutcome::Detached(detached), None));
762 }
763 std::thread::sleep(remaining.map_or(DETACH_POLL_INTERVAL, |remaining| {
764 remaining.min(DETACH_POLL_INTERVAL)
765 }));
766 }
767 }
768 }
769 }
770 };
771 let (stdout_bytes, stderr_bytes, output_holders) = finish_output(readers, pgid, drain_deadline);
772 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
773
774 let resource_limit_hit = classify_resource_limit(
777 &status,
778 enforcement.cgroup_scope.as_deref(),
779 cgroup_counters_before,
780 cgroup_occupancy.as_ref(),
781 )
782 .filter(|_| stopped.is_none());
783 drop(cgroup_occupancy);
784 let exit_code = exit_code_of(&status);
785
786 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
787 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
788 let mut truncated = false;
789 let mut full_output_path = None;
790
791 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
792 truncated = true;
793 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
794 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
795 let full_stdout = std::mem::replace(
796 &mut stdout,
797 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
798 );
799 let full_stderr = std::mem::replace(
800 &mut stderr,
801 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
802 );
803 if let Some(records_root) = records_root {
804 full_output_path = Some(write_shell_output_log(
805 records_root,
806 &format!("shell-{}", crate::trace::timestamp_ms()),
807 binary,
808 args,
809 exit_code,
810 &full_stdout,
811 &full_stderr,
812 )?);
813 }
814 }
815
816 Ok((
817 ShellOutcome::Finished(ShellResult {
818 binary: resolved_binary,
819 exit_code,
820 stdout,
821 stderr,
822 duration_ms,
823 truncated,
824 full_output_path,
825 resource_limit_hit,
826 output_holders,
827 }),
828 stopped,
829 ))
830}
831
832pub(crate) fn execute_shell(
837 binary: &str,
838 args: &[&str],
839 env_overrides: &[(String, String)],
840 accessible_workdir: &WorkdirRoot,
841 policy: &CapabilityPolicy,
842 enforcement: &crate::sandbox::ShellEnforcement,
843) -> Result<ShellResult, ShellExecError> {
844 match run_shell(
845 binary,
846 args,
847 env_overrides,
848 accessible_workdir,
849 policy,
850 enforcement,
851 None,
852 )? {
853 ShellOutcome::Finished(result) => Ok(result),
854 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
858 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
859 info.work_id
860 ))),
861 }
862}
863
864struct OutputReaders {
875 stdout: Option<PipeReader>,
876 stderr: Option<PipeReader>,
877}
878
879enum PipeSink {
881 Buffer(Vec<u8>),
883 Live(std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>),
885 Discard,
888}
889
890struct PipeReader {
892 thread: std::thread::JoinHandle<()>,
893 sink: std::sync::Arc<std::sync::Mutex<PipeSink>>,
894}
895
896fn lock_sink(sink: &std::sync::Mutex<PipeSink>) -> std::sync::MutexGuard<'_, PipeSink> {
897 sink.lock()
898 .unwrap_or_else(std::sync::PoisonError::into_inner)
899}
900
901impl PipeReader {
902 fn spawn(mut pipe: impl std::io::Read + Send + 'static) -> Self {
903 let sink = std::sync::Arc::new(std::sync::Mutex::new(PipeSink::Buffer(Vec::new())));
904 let thread = {
905 let sink = std::sync::Arc::clone(&sink);
906 std::thread::spawn(move || {
907 let mut chunk = [0u8; 8192];
908 loop {
909 match pipe.read(&mut chunk) {
910 Ok(0) => break,
911 Ok(read) => match &mut *lock_sink(&sink) {
912 PipeSink::Buffer(buffer) => buffer.extend_from_slice(&chunk[..read]),
913 PipeSink::Live(output) => output
914 .lock()
915 .unwrap_or_else(std::sync::PoisonError::into_inner)
916 .append(&chunk[..read]),
917 PipeSink::Discard => {}
918 },
919 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
920 Err(_) => break,
924 }
925 }
926 })
927 };
928 Self { thread, sink }
929 }
930
931 fn take(self) -> Vec<u8> {
934 let taken = match std::mem::replace(&mut *lock_sink(&self.sink), PipeSink::Discard) {
935 PipeSink::Buffer(buffer) => buffer,
936 PipeSink::Live(_) | PipeSink::Discard => Vec::new(),
937 };
938 if self.thread.is_finished() && self.thread.join().is_err() {
939 return Vec::new();
940 }
941 taken
942 }
943}
944
945impl OutputReaders {
946 fn spawn(child: &mut std::process::Child) -> Self {
947 Self {
948 stdout: child.stdout.take().map(PipeReader::spawn),
949 stderr: child.stderr.take().map(PipeReader::spawn),
950 }
951 }
952
953 fn go_live(&self, output: &std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>) {
959 let mut sinks: Vec<_> = [&self.stdout, &self.stderr]
960 .into_iter()
961 .flatten()
962 .map(|reader| lock_sink(&reader.sink))
963 .collect();
964 let mut live = output
965 .lock()
966 .unwrap_or_else(std::sync::PoisonError::into_inner);
967 for sink in &mut sinks {
968 if let PipeSink::Buffer(buffer) =
969 std::mem::replace(&mut **sink, PipeSink::Live(std::sync::Arc::clone(output)))
970 {
971 live.append(&buffer);
972 }
973 }
974 }
975
976 fn at_eof(&self) -> bool {
978 [&self.stdout, &self.stderr]
979 .into_iter()
980 .flatten()
981 .all(|reader| reader.thread.is_finished())
982 }
983
984 fn wait_for_eof(&self, deadline: Instant) -> bool {
987 loop {
988 if self.at_eof() {
989 return true;
990 }
991 let remaining = deadline.saturating_duration_since(Instant::now());
992 if remaining.is_zero() {
993 return false;
994 }
995 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
996 }
997 }
998
999 fn take(self) -> (Vec<u8>, Vec<u8>) {
1002 let take = |reader: Option<PipeReader>| reader.map(PipeReader::take).unwrap_or_default();
1003 (take(self.stdout), take(self.stderr))
1004 }
1005}
1006
1007fn finish_output(
1016 readers: OutputReaders,
1017 pgid: i32,
1018 drain_deadline: Instant,
1019) -> (Vec<u8>, Vec<u8>, OutputHolders) {
1020 if readers.wait_for_eof(drain_deadline) {
1021 let (stdout, stderr) = readers.take();
1022 return (stdout, stderr, OutputHolders::Closed);
1023 }
1024
1025 let give_up = Instant::now() + LEFTOVER_KILL_WAIT;
1026 let mut group_alive = true;
1027 let mut killed = false;
1028 let closed = loop {
1029 if group_alive {
1030 if crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL) {
1032 killed = true;
1033 } else {
1034 group_alive = false;
1035 }
1036 }
1037 if readers.at_eof() {
1038 break true;
1039 }
1040 let remaining = give_up.saturating_duration_since(Instant::now());
1041 if remaining.is_zero() {
1042 break false;
1043 }
1044 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
1045 };
1046
1047 let (stdout, stderr) = readers.take();
1048 let holders = match (closed, killed) {
1049 (true, true) => OutputHolders::Killed,
1050 (true, false) => OutputHolders::Closed,
1052 (false, _) => OutputHolders::Escaped,
1053 };
1054 (stdout, stderr, holders)
1055}
1056
1057struct DemotionContext {
1059 policy: DetachPolicy,
1060 records_root: WorkdirRoot,
1063 invoked_binary: String,
1065 resolved_binary: String,
1068 args: Vec<String>,
1069 started: Instant,
1070 pgid: i32,
1073 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
1074 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
1075 cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
1077}
1078
1079fn demote(
1092 mut child: std::process::Child,
1093 readers: OutputReaders,
1094 context: DemotionContext,
1095) -> DetachedDispatchInfo {
1096 let work_id = crate::detached::new_work_id();
1097 let info = DetachedDispatchInfo {
1098 work_id: work_id.clone(),
1099 binary: context.resolved_binary.clone(),
1100 command: context.policy.command.clone(),
1101 grace_ms: context
1102 .policy
1103 .grace
1104 .map(|grace| grace.as_millis().try_into().unwrap_or(u64::MAX)),
1105 };
1106
1107 let arg_refs: Vec<&str> = context.args.iter().map(String::as_str).collect();
1108 let log = crate::detached::output_path_for(&context.records_root, &work_id);
1109 let output = std::sync::Arc::new(std::sync::Mutex::new(crate::detached::LiveOutput::open(
1110 &context.records_root,
1111 &log.relative,
1112 &log_command_line(&context.invoked_binary, &arg_refs),
1113 )));
1114 readers.go_live(&output);
1115
1116 context.policy.registry.register(
1117 crate::detached::DetachedWork {
1118 work_id: work_id.clone(),
1119 binary: context.resolved_binary.clone(),
1120 command: context.policy.command.clone(),
1121 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
1127 context
1128 .started
1129 .elapsed()
1130 .as_millis()
1131 .try_into()
1132 .unwrap_or(u64::MAX),
1133 ),
1134 },
1135 context.started,
1136 context.pgid,
1137 log.path.clone(),
1138 std::sync::Arc::clone(&output),
1139 );
1140
1141 let provenance = context.policy.completion_provenance();
1142 let DemotionContext {
1143 policy,
1144 records_root,
1145 resolved_binary,
1146 started,
1147 pgid,
1148 cgroup_scope,
1149 cgroup_counters_before,
1150 cgroup_occupancy,
1151 ..
1152 } = context;
1153
1154 std::thread::spawn(move || {
1155 let observed = wait_for_exit_unreaped(pgid);
1156 policy.registry.leader_exited(&work_id);
1157 let waited = child.wait();
1158 let exited_at_ms = crate::trace::timestamp_ms();
1159 let (_, _, output_holders) =
1160 finish_output(readers, pgid, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
1161 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1162
1163 let (exit_code, resource_limit, mut error) = match waited {
1164 Ok(status) => (
1165 exit_code_of(&status),
1166 classify_resource_limit(
1167 &status,
1168 cgroup_scope.as_deref(),
1169 cgroup_counters_before,
1170 cgroup_occupancy.as_ref(),
1171 ),
1172 None,
1173 ),
1174 Err(failure) => (
1175 -1,
1176 None,
1177 Some(format!("waiting for the command failed: {failure}")),
1178 ),
1179 };
1180 drop(cgroup_occupancy);
1181 if let (Err(failure), None) = (observed, &error) {
1182 error = Some(format!("observing the command's exit failed: {failure}"));
1183 }
1184
1185 let write_error = output
1186 .lock()
1187 .unwrap_or_else(std::sync::PoisonError::into_inner)
1188 .finish(exit_code);
1189 if let Some(failure) = write_error {
1190 error = Some(match error {
1191 Some(existing) => format!("{existing}; {failure}"),
1192 None => failure,
1193 });
1194 }
1195 let output_bytes = records_root
1196 .file_len(&log.relative)
1197 .ok()
1198 .flatten()
1199 .unwrap_or(0);
1200
1201 policy
1202 .registry
1203 .complete(crate::detached::DetachedCompletion {
1204 work_id,
1205 binary: resolved_binary,
1206 command: policy.command.clone(),
1207 exit_code,
1208 duration_ms,
1209 exited_at_ms,
1210 output_path: log.path,
1211 output_bytes,
1212 resource_limit,
1213 output_holders,
1214 context_id: policy.context_id.clone(),
1215 provenance,
1216 error,
1217 });
1218 });
1219
1220 info
1221}
1222
1223pub(crate) fn wait_for_exit_unreaped(pid: i32) -> std::io::Result<()> {
1229 use rustix::process::{waitid, Pid, WaitId, WaitIdOptions};
1230
1231 let pid = (pid > 0)
1232 .then(|| Pid::from_raw(pid))
1233 .flatten()
1234 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
1235 loop {
1236 match waitid(
1237 WaitId::Pid(pid),
1238 WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
1239 ) {
1240 Ok(_) => return Ok(()),
1241 Err(rustix::io::Errno::INTR) => {}
1242 Err(errno) => return Err(errno.into()),
1243 }
1244 }
1245}
1246
1247pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
1255 use std::os::unix::process::ExitStatusExt;
1256
1257 status
1258 .code()
1259 .or_else(|| status.signal().map(|signal| 128 + signal))
1260 .unwrap_or(-1)
1261}
1262
1263fn classify_resource_limit(
1285 status: &std::process::ExitStatus,
1286 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
1287 counters_before: Option<crate::cgroup::CgroupEventCounters>,
1288 occupancy: Option<&crate::cgroup::ScopeOccupancy>,
1289) -> Option<String> {
1290 use std::os::unix::process::ExitStatusExt;
1291
1292 let signal_limit = status
1293 .signal()
1294 .and_then(crate::resources::limit_from_signal);
1295 let counter_delta = match (cgroup_scope, counters_before) {
1296 (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
1297 _ => None,
1298 };
1299 let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
1300 attribute_resource_limit(signal_limit, counter_delta, exclusive)
1301}
1302
1303pub(crate) fn attribute_resource_limit(
1310 signal_limit: Option<&'static str>,
1311 counter_delta: Option<&'static str>,
1312 exclusive: bool,
1313) -> Option<String> {
1314 signal_limit
1315 .or(counter_delta.filter(|_| exclusive))
1316 .map(str::to_string)
1317}
1318
1319pub(crate) fn build_shell_env(
1330 policy: &CapabilityPolicy,
1331 env_overrides: &[(String, String)],
1332 home_dir: Option<&std::path::Path>,
1333 runtime_dir: Option<&std::path::Path>,
1334 records_dir: Option<&std::path::Path>,
1335 tmpdir: Option<&str>,
1336) -> Result<BTreeMap<String, String>, String> {
1337 let mut env = BTreeMap::new();
1338
1339 for key in DEFAULT_ENV_BASELINE {
1340 if let Ok(value) = std::env::var(key) {
1341 env.insert((*key).to_string(), value);
1342 }
1343 }
1344
1345 for (key, value) in env_overrides {
1346 env.insert(key.clone(), value.clone());
1347 }
1348
1349 for key in &policy.shell_baseline_env {
1350 if crate::formation::is_member_grant_env(key)
1353 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1354 {
1355 continue;
1356 }
1357 if let Ok(value) = std::env::var(key) {
1358 env.insert(key.clone(), value);
1359 }
1360 }
1361
1362 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1363
1364 env.remove(ARTIFACT_CONFIG_ENV);
1367
1368 let home = home_dir.ok_or_else(|| NO_HOME_FOLDER_REFUSAL.to_string())?;
1372 let home = home.to_string_lossy().into_owned();
1373 env.insert("HOME".to_string(), home.clone());
1374 env.insert("USERPROFILE".to_string(), home);
1375 env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
1378 if let Some(runtime_dir) = runtime_dir {
1379 env.insert(
1380 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1381 runtime_dir.to_string_lossy().into_owned(),
1382 );
1383 }
1384 env.remove(crate::records_dir::RECORDS_DIR_ENV);
1386 if let Some(records_dir) = records_dir {
1387 env.insert(
1388 crate::records_dir::RECORDS_DIR_ENV.to_string(),
1389 records_dir.to_string_lossy().into_owned(),
1390 );
1391 }
1392 for name in crate::temp_dir::TEMP_DIR_ENV {
1394 env.remove(name);
1395 if let Some(tmpdir) = tmpdir {
1396 env.insert(name.to_string(), tmpdir.to_string());
1397 }
1398 }
1399
1400 Ok(env)
1401}
1402
1403pub(crate) const LINEAGE_ENV: &[&str] = &[
1410 crate::delegation::SPAWNER_ENV,
1411 crate::formation::FORMATION_ID_ENV,
1412 crate::lifeline::SPAWNER_LIFELINE_ENV,
1413 crate::host_warnings::HOST_WARNINGS_REPORTED_ENV,
1414 "MURMUR_ROOST_URL",
1415];
1416
1417pub(crate) fn subprocess_env(
1421 policy: &CapabilityPolicy,
1422 env_overrides: &[(String, String)],
1423 enforcement: &crate::sandbox::ShellEnforcement,
1424) -> Result<BTreeMap<OsString, OsString>, String> {
1425 let mut env: BTreeMap<OsString, OsString> =
1426 if enforcement.tier == crate::sandbox::EnforcementTier::Unconfined {
1427 build_unconfined_env(
1428 policy,
1429 std::env::vars_os(),
1430 env_overrides,
1431 enforcement.runtime_dir_path(),
1432 enforcement.records_dir_path(),
1433 )
1434 } else {
1435 build_shell_env(
1436 policy,
1437 env_overrides,
1438 enforcement.home_dir_path(),
1439 enforcement.runtime_dir_path(),
1440 enforcement.records_dir_path(),
1441 enforcement.tmpdir_value().as_deref(),
1442 )?
1443 .into_iter()
1444 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
1445 .collect()
1446 };
1447 append_pinned_program_path(&mut env, policy)?;
1448 Ok(env)
1449}
1450
1451pub(crate) fn append_pinned_program_path(
1456 env: &mut BTreeMap<OsString, OsString>,
1457 policy: &CapabilityPolicy,
1458) -> Result<(), String> {
1459 let pinned = crate::pinned_programs::pinned_bin_dirs(&policy.shell_pinned_programs);
1460 if pinned.is_empty() {
1461 return Ok(());
1462 }
1463 let key = OsString::from("PATH");
1464 let host = env.get(&key).filter(|path| !path.is_empty()).cloned();
1465 let host_dirs: Vec<std::path::PathBuf> = host
1466 .as_ref()
1467 .map(|path| std::env::split_paths(path).collect())
1468 .unwrap_or_default();
1469 let added: Vec<std::path::PathBuf> = pinned
1470 .into_iter()
1471 .filter(|dir| !host_dirs.contains(dir))
1472 .collect();
1473 if added.is_empty() {
1474 return Ok(());
1475 }
1476 let added = std::env::join_paths(added)
1477 .map_err(|err| format!("a pinned program folder cannot join PATH: {err}"))?;
1478 let joined = match host {
1479 Some(mut host) => {
1480 host.push(":");
1481 host.push(added);
1482 host
1483 }
1484 None => added,
1485 };
1486 env.insert(key, joined);
1487 Ok(())
1488}
1489
1490pub(crate) fn build_unconfined_env(
1500 policy: &CapabilityPolicy,
1501 base: impl IntoIterator<Item = (OsString, OsString)>,
1502 env_overrides: &[(String, String)],
1503 runtime_dir: Option<&std::path::Path>,
1504 records_dir: Option<&std::path::Path>,
1505) -> BTreeMap<OsString, OsString> {
1506 let mut env: BTreeMap<OsString, OsString> = base.into_iter().collect();
1507 for (key, value) in env_overrides {
1508 env.insert(OsString::from(key), OsString::from(value));
1509 }
1510 env.retain(|key, _| {
1511 let Some(key) = key.to_str() else {
1512 return true;
1513 };
1514 !(LINEAGE_ENV.contains(&key)
1515 || crate::formation::is_member_grant_env(key)
1516 || key == ARTIFACT_CONFIG_ENV
1517 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1518 || key == crate::records_dir::RECORDS_DIR_ENV
1519 || policy
1520 .shell_strip_env
1521 .iter()
1522 .any(|pattern| env_name_matches_pattern(pattern, key)))
1523 });
1524 if let Some(runtime_dir) = runtime_dir {
1525 env.insert(
1526 OsString::from(crate::runtime_dir::RUNTIME_DIR_ENV),
1527 runtime_dir.as_os_str().to_owned(),
1528 );
1529 }
1530 if let Some(records_dir) = records_dir {
1531 env.insert(
1532 OsString::from(crate::records_dir::RECORDS_DIR_ENV),
1533 records_dir.as_os_str().to_owned(),
1534 );
1535 }
1536 env
1537}
1538
1539pub(crate) fn strip_credential_shaped_vars(
1547 env: &mut BTreeMap<String, String>,
1548 extra_patterns: &[String],
1549) {
1550 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
1551}
1552
1553#[derive(Debug, Clone, PartialEq, Eq)]
1555pub enum BackstopPatternSource {
1556 Builtin,
1558 StripEnv,
1560}
1561
1562#[derive(Debug, Clone, PartialEq, Eq)]
1564pub struct BackstopMatch {
1565 pub pattern: String,
1566 pub source: BackstopPatternSource,
1567}
1568
1569pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
1578 CREDENTIAL_ENV_PATTERNS
1579 .iter()
1580 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
1581 .chain(
1582 extra_patterns
1583 .iter()
1584 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
1585 )
1586 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
1587 .map(|(pattern, source)| BackstopMatch {
1588 pattern: pattern.to_string(),
1589 source,
1590 })
1591}
1592
1593pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
1597 credential_backstop_match(name, extra_patterns).is_some()
1598}
1599
1600pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
1616 let mut env = BTreeMap::new();
1617
1618 for key in &policy.env_allow {
1619 if key == ARTIFACT_CONFIG_ENV
1620 || crate::formation::is_member_grant_env(key)
1621 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1622 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1623 || key == crate::records_dir::RECORDS_DIR_ENV
1624 {
1625 continue;
1626 }
1627 if let Ok(value) = std::env::var(key) {
1628 env.insert(key.clone(), value);
1629 }
1630 }
1631
1632 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1633
1634 env
1635}
1636
1637fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
1638 if let Some(rest) = pattern.strip_prefix('*') {
1639 if let Some(middle) = rest.strip_suffix('*') {
1640 return key.contains(middle);
1641 }
1642 return key.ends_with(rest);
1643 }
1644
1645 if let Some(prefix) = pattern.strip_suffix('*') {
1646 return key.starts_with(prefix);
1647 }
1648
1649 pattern == key
1650}
1651
1652fn log_command_line(binary: &str, args: &[&str]) -> String {
1654 if args.is_empty() {
1655 binary.to_string()
1656 } else {
1657 format!("{binary} {}", args.join(" "))
1658 }
1659}
1660
1661fn write_shell_output_log(
1669 records_root: &WorkdirRoot,
1670 file_stem: &str,
1671 binary: &str,
1672 args: &[&str],
1673 exit_code: i32,
1674 stdout: &str,
1675 stderr: &str,
1676) -> Result<String, String> {
1677 let log = crate::detached::output_path_for(records_root, file_stem);
1678 let command = log_command_line(binary, args);
1679
1680 let content = format!(
1681 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1682 );
1683
1684 records_root
1685 .write_file(&log.relative, content.as_bytes(), DEFAULT_FILE_MODE)
1686 .map_err(|error| format!("failed to write shell log: {error}"))?;
1687
1688 Ok(log.path)
1689}
1690
1691#[cfg(test)]
1692mod tests {
1693 use std::{fs, path::Path};
1694 use tempfile::tempdir;
1695
1696 use super::test_support::{process_dead_within, read_pid, KillPidFileOnDrop};
1697 use super::*;
1698 use crate::sandbox::ShellEnforcement;
1699
1700 #[test]
1706 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1707 let temp = tempdir().unwrap();
1708 let policy = CapabilityPolicy {
1709 shell_allow: vec!["bash".to_string()],
1710 resources: crate::resources::HostResourceLimits {
1711 max_open_files: 64,
1712 ..crate::resources::HostResourceLimits::default()
1713 },
1714 ..CapabilityPolicy::default()
1715 };
1716 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1717 enforcement.resource_limits = policy.resources;
1718
1719 let result = execute_shell(
1720 "bash",
1721 &["-c", "ulimit -Hn"],
1722 &[],
1723 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1724 &policy,
1725 &enforcement,
1726 )
1727 .expect("bash must run");
1728
1729 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1730 assert_eq!(result.resource_limit_hit, None);
1731 }
1732
1733 #[cfg(target_os = "linux")]
1735 fn run_bash_with_limits(
1736 limits: crate::resources::HostResourceLimits,
1737 script: &str,
1738 ) -> ShellResult {
1739 let temp = tempdir().unwrap();
1740 let policy = CapabilityPolicy {
1741 shell_allow: vec!["bash".to_string()],
1742 resources: limits,
1743 ..CapabilityPolicy::default()
1744 };
1745 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1746 enforcement.resource_limits = limits;
1747
1748 execute_shell(
1749 "bash",
1750 &["-c", script],
1751 &[],
1752 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1753 &policy,
1754 &enforcement,
1755 )
1756 .expect("bash must run")
1757 }
1758
1759 #[cfg(target_os = "linux")]
1765 #[test]
1766 fn a_command_that_ignores_sigxcpu_is_killed_one_second_later() {
1767 let result = run_bash_with_limits(
1768 crate::resources::HostResourceLimits {
1769 cpu_seconds: Some(1),
1770 ..crate::resources::HostResourceLimits::default()
1771 },
1772 "( trap '' XCPU; while :; do :; done ); echo \"status=$?\"; times",
1773 );
1774
1775 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1776 assert!(
1777 result.stdout.contains("status=137"),
1778 "the subshell must die of SIGKILL; stdout was: {}",
1779 result.stdout
1780 );
1781 assert_eq!(result.resource_limit_hit, None);
1782
1783 let children = result
1784 .stdout
1785 .lines()
1786 .last()
1787 .unwrap_or_else(|| panic!("no children line from `times`: {}", result.stdout));
1788 let cpu: f64 = children
1789 .split_whitespace()
1790 .map(|field| {
1791 let (minutes, seconds) = field.trim_end_matches('s').split_once('m').unwrap();
1792 minutes.parse::<f64>().unwrap() * 60.0 + seconds.parse::<f64>().unwrap()
1793 })
1794 .sum();
1795 assert!(
1796 cpu <= 2.5,
1797 "the subshell ran {cpu} CPU-seconds, past cpu_seconds + 1"
1798 );
1799 }
1800
1801 #[cfg(target_os = "linux")]
1805 #[test]
1806 #[allow(unsafe_code)]
1807 fn the_cpu_limit_warns_one_second_before_its_hard_ceiling() {
1808 let result = run_bash_with_limits(
1809 crate::resources::HostResourceLimits {
1810 cpu_seconds: Some(5),
1811 max_open_files: 64,
1812 ..crate::resources::HostResourceLimits::default()
1813 },
1814 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)"; ulimit -Ht 10 2>/dev/null || echo "raise refused""#,
1815 );
1816
1817 let mut lines = result.stdout.lines();
1818 assert_eq!(
1819 lines.next(),
1820 Some("5 6 64 64"),
1821 "stderr was: {}",
1822 result.stderr
1823 );
1824 if unsafe { libc::geteuid() } != 0 {
1826 assert_eq!(lines.next(), Some("raise refused"));
1827 }
1828 }
1829
1830 #[cfg(target_os = "linux")]
1833 #[test]
1834 #[allow(clippy::unnecessary_cast)]
1835 fn with_no_cpu_seconds_a_command_keeps_the_inherited_cpu_limit() {
1836 let own = crate::resources::read_rlimit(libc::RLIMIT_CPU as u32)
1837 .expect("getrlimit(RLIMIT_CPU) failed");
1838 let render = |slot: libc::rlim_t| {
1839 if slot == libc::RLIM_INFINITY {
1840 "unlimited".to_string()
1841 } else {
1842 slot.to_string()
1843 }
1844 };
1845
1846 let result = run_bash_with_limits(
1847 crate::resources::HostResourceLimits::resolve(None),
1848 r#"echo "$(ulimit -St) $(ulimit -Ht)""#,
1849 );
1850
1851 assert_eq!(
1852 result.stdout.trim(),
1853 format!("{} {}", render(own.rlim_cur), render(own.rlim_max)),
1854 "stderr was: {}",
1855 result.stderr
1856 );
1857 }
1858
1859 #[cfg(target_os = "linux")]
1861 #[test]
1862 fn with_no_cpu_seconds_a_cpu_bound_command_runs_to_its_end() {
1863 let result = run_bash_with_limits(
1864 crate::resources::HostResourceLimits::resolve(None),
1865 "end=$((SECONDS + 3)); while [ \"$SECONDS\" -lt \"$end\" ]; do :; done; echo done",
1866 );
1867
1868 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1869 assert_eq!(result.stdout.trim(), "done");
1870 assert_eq!(result.resource_limit_hit, None);
1871 }
1872
1873 #[cfg(target_os = "linux")]
1878 #[test]
1879 fn a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors() {
1880 const TEST: &str = "a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors";
1881 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1882 return;
1883 }
1884 let temp = tempdir().unwrap();
1885 let policy = CapabilityPolicy {
1886 shell_allow: vec!["bash".to_string()],
1887 resources: crate::resources::HostResourceLimits {
1888 max_open_files: 16,
1889 ..crate::resources::HostResourceLimits::default()
1890 },
1891 ..CapabilityPolicy::default()
1892 };
1893 let enforcement = ShellEnforcement::resolve_declared(
1894 &policy,
1895 murmur_artifact::ContainmentClass::Scoped,
1896 crate::sandbox::HostProbe::probe(),
1897 )
1898 .unwrap()
1899 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1900 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1901 crate::runtime_err!(
1902 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1903 enforcement.tier
1904 );
1905 return;
1906 }
1907 let held: Vec<fs::File> = (0..128)
1908 .map(|_| fs::File::open("/dev/null").unwrap())
1909 .collect();
1910
1911 let result = execute_shell(
1912 "bash",
1913 &["-c", "ulimit -Hn"],
1914 &[],
1915 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1916 &policy,
1917 &enforcement,
1918 )
1919 .expect("bash must start under the limit");
1920 drop(held);
1921
1922 assert_eq!(result.stdout.trim(), "16", "stderr was: {}", result.stderr);
1923 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1924 }
1925
1926 #[cfg(target_os = "linux")]
1930 #[test]
1931 fn a_program_outside_the_allowlist_is_refused_with_exit_126() {
1932 const TEST: &str = "a_program_outside_the_allowlist_is_refused_with_exit_126";
1933 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1934 return;
1935 }
1936 let run = |allow: &[&str]| {
1937 let temp = tempdir().unwrap();
1938 let policy = CapabilityPolicy {
1939 shell_allow: allow.iter().map(|b| b.to_string()).collect(),
1940 ..CapabilityPolicy::default()
1941 };
1942 let enforcement = ShellEnforcement::resolve_declared(
1943 &policy,
1944 murmur_artifact::ContainmentClass::Scoped,
1945 crate::sandbox::HostProbe::probe(),
1946 )
1947 .unwrap()
1948 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1949 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1950 crate::runtime_err!(
1951 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1952 enforcement.tier
1953 );
1954 return None;
1955 }
1956 let result = execute_shell(
1957 "bash",
1958 &["-c", "cat /dev/null"],
1959 &[],
1960 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1961 &policy,
1962 &enforcement,
1963 )
1964 .expect("bash must start");
1965 Some(result)
1966 };
1967
1968 let Some(refused) = run(&["bash"]) else {
1969 return;
1970 };
1971 assert_eq!(refused.exit_code, 126, "stderr was: {}", refused.stderr);
1972 assert!(
1973 refused.stderr.contains("Permission denied"),
1974 "stderr was: {}",
1975 refused.stderr
1976 );
1977
1978 let allowed = run(&["bash", "cat"]).expect("the tier did not change between runs");
1979 assert_eq!(allowed.exit_code, 0, "stderr was: {}", allowed.stderr);
1980 crate::runtime_err!("{TEST}: ran on KernelFull; [bash] exited 126, [bash, cat] exited 0");
1981 }
1982
1983 #[cfg(target_os = "linux")]
1986 #[test]
1987 #[allow(clippy::print_stdout)]
1988 fn a_kernel_tier_command_gets_the_cpu_slots() {
1989 const TEST: &str = "a_kernel_tier_command_gets_the_cpu_slots";
1990 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1991 return;
1992 }
1993 let temp = tempdir().unwrap();
1994 let policy = CapabilityPolicy {
1995 shell_allow: vec!["bash".to_string()],
1996 resources: crate::resources::HostResourceLimits {
1997 cpu_seconds: Some(5),
1998 max_open_files: 64,
1999 ..crate::resources::HostResourceLimits::default()
2000 },
2001 ..CapabilityPolicy::default()
2002 };
2003 let enforcement = ShellEnforcement::resolve_declared(
2004 &policy,
2005 murmur_artifact::ContainmentClass::Scoped,
2006 crate::sandbox::HostProbe::probe(),
2007 )
2008 .unwrap()
2009 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
2010 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
2011 crate::runtime_err!(
2012 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
2013 enforcement.tier
2014 );
2015 return;
2016 }
2017
2018 let result = execute_shell(
2019 "bash",
2020 &[
2021 "-c",
2022 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)""#,
2023 ],
2024 &[],
2025 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2026 &policy,
2027 &enforcement,
2028 )
2029 .expect("bash must start under the limits");
2030 println!("{}", result.stdout.trim());
2031
2032 assert_eq!(
2033 result.stdout.trim(),
2034 "5 6 64 64",
2035 "stderr was: {}",
2036 result.stderr
2037 );
2038 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
2039 }
2040
2041 #[cfg(target_os = "linux")]
2045 #[test]
2046 fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
2047 const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
2048 if !crate::cgroup::cgroup_delegation_available() {
2049 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
2050 return;
2051 }
2052
2053 let temp = tempdir().unwrap();
2054 let limits = crate::resources::HostResourceLimits {
2055 cgroup_pids_max: 16,
2056 ..crate::resources::HostResourceLimits::default()
2057 };
2058 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
2059 .expect("this host delegates a cgroup scope")
2060 .scope;
2061 let policy = CapabilityPolicy {
2062 shell_allow: vec!["python3".to_string()],
2063 resources: limits,
2064 ..CapabilityPolicy::default()
2065 };
2066 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
2067 enforcement.resource_limits = limits;
2068 let enforcement = enforcement.with_host_bounding(scope, None);
2069
2070 let script = "import os, resource, signal, time\n\
2071 children = []\n\
2072 for _ in range(4096):\n\
2073 \x20 try:\n\
2074 \x20 pid = os.fork()\n\
2075 \x20 except OSError:\n\
2076 \x20 break\n\
2077 \x20 if pid == 0:\n\
2078 \x20 time.sleep(30)\n\
2079 \x20 os._exit(0)\n\
2080 \x20 children.append(pid)\n\
2081 for pid in children:\n\
2082 \x20 os.kill(pid, signal.SIGKILL)\n\
2083 for pid in children:\n\
2084 \x20 os.waitpid(pid, 0)\n\
2085 soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
2086 print(len(children))\n\
2087 print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
2088 let result = execute_shell(
2089 "python3",
2090 &["-c", script],
2091 &[],
2092 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2093 &policy,
2094 &enforcement,
2095 )
2096 .expect("python3 must run");
2097
2098 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
2099 let mut lines = result.stdout.lines();
2100 let reached: u64 = lines.next().unwrap().parse().unwrap();
2101 let soft = lines.next().unwrap();
2102 assert!(
2103 reached < 16,
2104 "pids.max is 16 and the python process holds one task, so at most 15 children can \
2105 be live; reached {reached}"
2106 );
2107 assert_eq!(
2108 result.resource_limit_hit,
2109 Some("cgroup_pids_max".to_string())
2110 );
2111 let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
2112 assert_eq!(
2113 soft,
2114 crate::resources::test_support::rendered_rlimit(own_soft),
2115 "a scoped spawn inherits RLIMIT_NPROC unchanged"
2116 );
2117 }
2118
2119 #[test]
2126 fn home_folder_is_home_and_the_work_folder_gains_nothing() {
2127 let accessible = tempdir().unwrap();
2128 let runtime = tempdir().unwrap();
2129 let enforcement = ShellEnforcement::environment_only()
2130 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2131 let home = enforcement.home_dir_path().unwrap().to_path_buf();
2132 let policy = CapabilityPolicy {
2133 shell_allow: vec!["bash".to_string()],
2134 ..CapabilityPolicy::default()
2135 };
2136
2137 let result = execute_shell(
2138 "bash",
2139 &[
2140 "-c",
2141 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && echo \"$HOME\" && pwd",
2142 ],
2143 &[],
2144 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
2145 &policy,
2146 &enforcement,
2147 )
2148 .expect("bash must run");
2149
2150 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
2151 let mut lines = result.stdout.lines();
2152 assert_eq!(lines.next(), Some("marker"));
2153 assert_eq!(lines.next(), Some(home.to_str().unwrap()));
2154 assert_eq!(
2155 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
2156 std::fs::canonicalize(accessible.path()).unwrap(),
2157 "the subprocess cwd is the work folder"
2158 );
2159 assert_eq!(fs::read_to_string(home.join("probe")).unwrap(), "marker\n");
2160 assert_eq!(
2161 fs::read_dir(accessible.path()).unwrap().count(),
2162 0,
2163 "the work folder gains nothing"
2164 );
2165 }
2166
2167 fn print_seq(workdir: &Path, enforcement: &ShellEnforcement, last: u32) -> ShellResult {
2170 let policy = CapabilityPolicy {
2171 shell_allow: vec!["bash".to_string()],
2172 ..CapabilityPolicy::default()
2173 };
2174 let command = format!("seq 1 {last}");
2175 execute_shell(
2176 "bash",
2177 &["-c", &command],
2178 &[],
2179 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
2180 &policy,
2181 enforcement,
2182 )
2183 .expect("bash must run")
2184 }
2185
2186 #[test]
2189 fn a_truncated_output_log_lands_in_the_records_directory() {
2190 let workdir = tempdir().unwrap();
2191 let runtime = tempdir().unwrap();
2192 let enforcement = ShellEnforcement::environment_only()
2193 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2194 let records_root = enforcement.records_root().unwrap().clone();
2195
2196 let result = print_seq(workdir.path(), &enforcement, 20_000);
2197
2198 assert!(result.truncated);
2199 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
2200 let path = result
2201 .full_output_path
2202 .expect("a truncated command names its log");
2203 let file_name = Path::new(&path).file_name().unwrap().to_str().unwrap();
2204 let ms = file_name
2205 .strip_prefix("shell-")
2206 .and_then(|rest| rest.strip_suffix(".log"))
2207 .unwrap_or_else(|| panic!("{path} is not logs/shell-<ms>.log"));
2208 assert!(ms.chars().all(|c| c.is_ascii_digit()), "{path}");
2209 assert_eq!(
2210 Path::new(&path),
2211 records_root.host_path("logs").join(file_name),
2212 );
2213 assert!(Path::new(&path).is_absolute(), "{path}");
2214
2215 let log = fs::read_to_string(&path).unwrap();
2216 let expected: String = (1..=20_000).map(|n| format!("{n}\n")).collect();
2217 let stdout = log
2218 .split_once("\nStdout:\n")
2219 .and_then(|(_, rest)| rest.split_once("\n\nStderr:\n"))
2220 .map(|(stdout, _)| stdout)
2221 .expect("the log keeps its Stdout: and Stderr: sections");
2222 assert_eq!(stdout, expected, "the log holds the full stdout");
2223 assert!(log.starts_with("Command: bash -c seq 1 20000\nExit code: 0\n"));
2224 assert!(
2225 log.ends_with("\n\nStderr:\n\n"),
2226 "the log keeps the empty Stderr: section"
2227 );
2228
2229 assert!(
2230 !workdir.path().join("logs").exists(),
2231 "nothing is written into the accessible workdir"
2232 );
2233 assert_eq!(fs::read_dir(workdir.path()).unwrap().count(), 0);
2234 }
2235
2236 #[test]
2239 fn with_no_records_directory_a_truncated_command_writes_no_log() {
2240 let runtime = tempdir().unwrap();
2241 let open = |sub: &str| {
2242 let path = runtime.path().join(sub);
2243 fs::create_dir_all(&path).unwrap();
2244 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
2245 };
2246 let roots_without_records = ShellEnforcement::environment_only().with_runtime_dir(
2247 crate::runtime_dir::RuntimeDirRoots::new(
2248 open(crate::runtime_dir::RUNTIME_SUBDIR),
2249 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
2250 open(crate::runtime_dir::HARNESS_SUBDIR),
2251 )
2252 .with_home(open(crate::records_dir::HOME_SUBDIR)),
2253 None,
2254 );
2255
2256 let enforcement = &roots_without_records;
2257 let workdir = tempdir().unwrap();
2258 let result = print_seq(workdir.path(), enforcement, 20_000);
2259 assert!(result.truncated);
2260 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
2261 assert_eq!(result.full_output_path, None);
2262 assert_eq!(
2263 fs::read_dir(workdir.path()).unwrap().count(),
2264 0,
2265 "nothing is written into the workdir"
2266 );
2267 assert!(!runtime.path().join("logs").exists());
2268 assert!(!runtime
2269 .path()
2270 .join(crate::runtime_dir::RUNTIME_SUBDIR)
2271 .join("logs")
2272 .exists());
2273 }
2274
2275 #[test]
2278 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
2279 let temp = tempdir().unwrap();
2280 let policy = CapabilityPolicy {
2281 shell_allow: vec!["bash".to_string()],
2282 ..CapabilityPolicy::default()
2283 };
2284
2285 let result = execute_shell(
2286 "bash",
2287 &["-c", "exit 3"],
2288 &[],
2289 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2290 &policy,
2291 &ShellEnforcement::environment_only().with_test_session_dirs(),
2292 )
2293 .expect("bash must run");
2294
2295 assert_eq!(result.exit_code, 3);
2296 assert_eq!(result.resource_limit_hit, None);
2297 }
2298
2299 #[test]
2300 fn execute_shell_blocks_binary_not_in_allowlist() {
2301 let policy = CapabilityPolicy::default();
2302 let error = execute_shell(
2303 "bash",
2304 &["-c", "echo hi"],
2305 &[],
2306 &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
2307 &policy,
2308 &ShellEnforcement::environment_only().with_test_session_dirs(),
2309 )
2310 .unwrap_err();
2311
2312 assert!(error.to_string().contains("capabilities.shell.allow"));
2313 }
2314
2315 #[test]
2316 fn execute_shell_returns_nonzero_exit_code_without_error() {
2317 let temp = tempdir().unwrap();
2318 let policy = CapabilityPolicy {
2319 shell_allow: vec!["bash".to_string()],
2320 ..CapabilityPolicy::default()
2321 };
2322
2323 let result = execute_shell(
2324 "bash",
2325 &["-c", "exit 42"],
2326 &[],
2327 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2328 &policy,
2329 &ShellEnforcement::environment_only().with_test_session_dirs(),
2330 )
2331 .unwrap();
2332 assert_eq!(result.exit_code, 42);
2333 }
2334
2335 #[test]
2339 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
2340 let temp = tempdir().unwrap();
2341 let policy = CapabilityPolicy {
2342 shell_allow: vec!["bash".to_string()],
2343 ..CapabilityPolicy::default()
2344 };
2345
2346 let result = execute_shell(
2347 "bash",
2348 &["-c", "exit 0"],
2349 &[],
2350 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2351 &policy,
2352 &ShellEnforcement::environment_only().with_test_session_dirs(),
2353 )
2354 .unwrap();
2355
2356 assert!(
2357 Path::new(&result.binary).is_absolute(),
2358 "a binary found on PATH must be reported as an absolute path, got {:?}",
2359 result.binary
2360 );
2361 assert!(
2362 result.binary.ends_with("bash"),
2363 "the reported path must still name bash, got {:?}",
2364 result.binary
2365 );
2366 }
2367
2368 #[test]
2374 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
2375 let temp = tempdir().unwrap();
2376 let tool = temp.path().join("local-tool");
2377 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
2378 let mut perms = fs::metadata(&tool).unwrap().permissions();
2379 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2380 fs::set_permissions(&tool, perms).unwrap();
2381
2382 let policy = CapabilityPolicy {
2383 shell_allow: vec!["./local-tool".to_string()],
2384 ..CapabilityPolicy::default()
2385 };
2386
2387 let result = execute_shell(
2388 "./local-tool",
2389 &[],
2390 &[],
2391 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2392 &policy,
2393 &ShellEnforcement::environment_only().with_test_session_dirs(),
2394 )
2395 .unwrap();
2396
2397 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
2398 assert_eq!(
2399 result.binary, "./local-tool",
2400 "an unresolvable name degrades to the bare invoked name, never an error"
2401 );
2402 }
2403
2404 #[test]
2405 fn build_shell_env_sets_home_to_the_home_folder() {
2406 let temp = tempdir().unwrap();
2407 let policy = CapabilityPolicy::default();
2408
2409 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2410
2411 let expected_home = temp.path().to_string_lossy().into_owned();
2412 assert_eq!(env.get("HOME"), Some(&expected_home));
2413 assert_eq!(env.get("USERPROFILE"), Some(&expected_home));
2414 }
2415
2416 #[test]
2417 fn no_home_folder_build_shell_env_refuses() {
2418 let error =
2419 build_shell_env(&CapabilityPolicy::default(), &[], None, None, None, None).unwrap_err();
2420 assert_eq!(error, NO_HOME_FOLDER_REFUSAL);
2421 assert!(error.contains("home folder"), "{error}");
2422 }
2423
2424 #[test]
2428 fn no_home_folder_execute_shell_refuses_and_creates_nothing() {
2429 let parent = tempdir().unwrap();
2430 let workdir = parent.path().join("work");
2431 fs::create_dir(&workdir).unwrap();
2432 let runtime = tempdir().unwrap();
2433 let roots = crate::sandbox::test_runtime_dir(runtime.path());
2434 let without_home = crate::runtime_dir::RuntimeDirRoots {
2435 home: None,
2436 ..roots
2437 };
2438 let policy = CapabilityPolicy {
2439 shell_allow: vec!["bash".to_string()],
2440 ..CapabilityPolicy::default()
2441 };
2442 for enforcement in [
2443 ShellEnforcement::environment_only(),
2444 ShellEnforcement::environment_only().with_runtime_dir(without_home.clone(), None),
2445 ] {
2446 let error = execute_shell(
2447 "bash",
2448 &["-c", "touch ran"],
2449 &[],
2450 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
2451 &policy,
2452 &enforcement,
2453 )
2454 .unwrap_err();
2455 assert!(error.to_string().contains("no home folder"), "{error}");
2456 }
2457 assert_eq!(fs::read_dir(&workdir).unwrap().count(), 0);
2458 assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1);
2459 }
2460
2461 #[test]
2462 fn execute_shell_reports_the_home_folder_not_real_host_home() {
2463 let temp = tempdir().unwrap();
2464 let runtime = tempdir().unwrap();
2465 let enforcement = ShellEnforcement::environment_only()
2466 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2467 let policy = CapabilityPolicy {
2468 shell_allow: vec!["bash".to_string()],
2469 ..CapabilityPolicy::default()
2470 };
2471
2472 let result = execute_shell(
2473 "bash",
2474 &["-c", "echo -n $HOME"],
2475 &[],
2476 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2477 &policy,
2478 &enforcement,
2479 )
2480 .unwrap();
2481
2482 let expected_home = runtime.path().join("records/home");
2483 assert_eq!(result.stdout, expected_home.to_string_lossy());
2484 if let Ok(real_home) = std::env::var("HOME") {
2485 assert_ne!(result.stdout, real_home);
2486 }
2487 }
2488
2489 #[test]
2490 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
2491 let temp = tempdir().unwrap();
2492 let policy = CapabilityPolicy::default();
2493 let overrides = vec![
2494 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
2495 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
2496 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
2497 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
2498 ("SAFE_VAR".to_string(), "kept".to_string()),
2499 ];
2500
2501 let env =
2502 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2503
2504 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
2505 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
2506 assert!(!env.contains_key("STRIPE_API_KEY"));
2507 assert!(!env.contains_key("GITHUB_TOKEN"));
2508 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
2509 }
2510
2511 #[test]
2512 fn declared_env_is_empty_without_declarations() {
2513 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
2514 let policy = CapabilityPolicy::default();
2515
2516 let env = build_declared_env(&policy);
2517
2518 assert!(env.is_empty());
2519 }
2520
2521 #[test]
2524 fn declared_and_shell_env_never_carry_the_formation_lifeline() {
2525 use crate::lifeline::FORMATION_LIFELINE_ENV;
2526 let _guard = crate::formation::FORMATION_ENV_LOCK
2527 .lock()
2528 .unwrap_or_else(|poisoned| poisoned.into_inner());
2529 let temp = tempdir().unwrap();
2530 std::env::set_var(FORMATION_LIFELINE_ENV, "7");
2531 let policy = CapabilityPolicy {
2532 env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
2533 shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
2534 ..CapabilityPolicy::default()
2535 };
2536 let declared = build_declared_env(&policy);
2537 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2538 std::env::remove_var(FORMATION_LIFELINE_ENV);
2539 assert!(
2540 !declared.contains_key(FORMATION_LIFELINE_ENV),
2541 "{declared:?}"
2542 );
2543 assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
2544 }
2545
2546 #[test]
2548 fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
2549 use crate::lifeline::SPAWNER_LIFELINE_ENV;
2550 let _guard = crate::formation::FORMATION_ENV_LOCK
2551 .lock()
2552 .unwrap_or_else(|poisoned| poisoned.into_inner());
2553 let temp = tempdir().unwrap();
2554 std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
2555 let policy = CapabilityPolicy {
2556 env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
2557 shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
2558 ..CapabilityPolicy::default()
2559 };
2560 let declared = build_declared_env(&policy);
2561 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2562 std::env::remove_var(SPAWNER_LIFELINE_ENV);
2563 assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
2564 assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
2565 }
2566
2567 #[test]
2568 fn declared_env_passes_through_declared_host_var() {
2569 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
2570 let policy = CapabilityPolicy {
2571 env_allow: vec![
2572 "MURMUR_TEST_WASI_ALLOWED".to_string(),
2573 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
2575 ],
2576 ..CapabilityPolicy::default()
2577 };
2578
2579 let env = build_declared_env(&policy);
2580
2581 assert_eq!(
2582 env.get("MURMUR_TEST_WASI_ALLOWED"),
2583 Some(&"host-value".to_string())
2584 );
2585 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
2586 }
2587
2588 #[test]
2589 fn declared_env_strips_credential_shaped_declarations() {
2590 std::env::set_var("GITHUB_TOKEN", "leaked-token");
2591 std::env::set_var("STRIPE_API_KEY", "leaked-key");
2592 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
2593 let policy = CapabilityPolicy {
2594 env_allow: vec![
2595 "GITHUB_TOKEN".to_string(),
2596 "STRIPE_API_KEY".to_string(),
2597 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
2598 ],
2599 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
2600 ..CapabilityPolicy::default()
2601 };
2602
2603 let env = build_declared_env(&policy);
2604
2605 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
2607 }
2608
2609 #[test]
2612 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
2613 std::env::set_var("PRIVATE_KEY", "host-private-key");
2614 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
2615 let policy = CapabilityPolicy {
2616 env_allow: vec![
2617 "PRIVATE_KEY".to_string(),
2618 "MURMUR_TEST_WASI_PLAIN".to_string(),
2619 ],
2620 ..CapabilityPolicy::default()
2621 };
2622
2623 let env = build_declared_env(&policy);
2624
2625 assert_eq!(
2626 env.get("PRIVATE_KEY"),
2627 Some(&"host-private-key".to_string())
2628 );
2629 assert_eq!(
2630 env.get("MURMUR_TEST_WASI_PLAIN"),
2631 Some(&"host-plain".to_string())
2632 );
2633 }
2634
2635 #[test]
2636 fn build_shell_env_home_override_cannot_survive() {
2637 let temp = tempdir().unwrap();
2638 let policy = CapabilityPolicy {
2639 shell_baseline_env: vec!["HOME".to_string()],
2640 ..CapabilityPolicy::default()
2641 };
2642 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
2643
2644 let env =
2645 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2646
2647 assert_eq!(
2648 env.get("HOME"),
2649 Some(&temp.path().to_string_lossy().into_owned())
2650 );
2651 }
2652
2653 #[test]
2654 fn build_shell_env_keeps_safe_baseline_vars() {
2655 let temp = tempdir().unwrap();
2656 let policy = CapabilityPolicy::default();
2657
2658 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
2659 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2660 std::env::remove_var("CARGO_HOME");
2661
2662 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
2663 }
2664
2665 #[test]
2668 fn build_shell_env_carries_the_hosts_account_names() {
2669 let temp = tempdir().unwrap();
2670 let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
2671 let _guard = ACCOUNT_ENV_LOCK
2672 .lock()
2673 .unwrap_or_else(|poisoned| poisoned.into_inner());
2674 std::env::set_var("USER", "murmur-test-user");
2675 std::env::set_var("LOGNAME", "murmur-test-login");
2676
2677 let env = build_shell_env(
2678 &CapabilityPolicy::default(),
2679 &[],
2680 Some(root.path()),
2681 None,
2682 None,
2683 None,
2684 )
2685 .unwrap();
2686 let stripped = build_shell_env(
2687 &CapabilityPolicy {
2688 shell_strip_env: vec!["LOGNAME".to_string()],
2689 ..CapabilityPolicy::default()
2690 },
2691 &[],
2692 Some(root.path()),
2693 None,
2694 None,
2695 None,
2696 )
2697 .unwrap();
2698
2699 assert_eq!(
2700 env.get("USER").map(String::as_str),
2701 Some("murmur-test-user")
2702 );
2703 assert_eq!(
2704 env.get("LOGNAME").map(String::as_str),
2705 Some("murmur-test-login")
2706 );
2707 assert_eq!(
2708 env.get("HOME"),
2709 Some(&temp.path().to_string_lossy().into_owned())
2710 );
2711 assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
2712 assert_eq!(
2713 stripped.get("USER").map(String::as_str),
2714 Some("murmur-test-user")
2715 );
2716 let guest = build_declared_env(&CapabilityPolicy::default());
2717 assert!(
2718 !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
2719 "a WASM guest is handed only what env.allow declares: {guest:?}"
2720 );
2721 }
2722
2723 #[test]
2724 fn host_account_with_both_names_set_reports_none_unset() {
2725 let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
2726 assert!(account.unset().is_empty());
2727 let mut env = BTreeMap::new();
2728 account.apply(&mut env);
2729 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2730 assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
2731 }
2732
2733 #[test]
2735 fn host_account_with_one_name_set_never_copies_it_to_the_other() {
2736 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2737 assert_eq!(account.unset(), ["LOGNAME".to_string()]);
2738 let mut env = BTreeMap::new();
2739 account.apply(&mut env);
2740 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2741 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2742 }
2743
2744 #[test]
2745 fn host_account_with_neither_name_set_reports_both_sorted() {
2746 let account = HostAccountEnv::from_pairs(&[]);
2747 assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
2748 let mut env = BTreeMap::new();
2749 account.apply(&mut env);
2750 assert!(env.is_empty(), "{env:?}");
2751 }
2752
2753 #[test]
2756 fn host_account_apply_replaces_a_stale_value() {
2757 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2758 let mut env = BTreeMap::from([
2759 ("USER".to_string(), "someone-else".to_string()),
2760 ("LOGNAME".to_string(), "someone-else".to_string()),
2761 ("PATH".to_string(), "/bin".to_string()),
2762 ]);
2763 account.apply(&mut env);
2764 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2765 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2766 assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
2767 }
2768
2769 #[test]
2770 fn build_shell_env_developer_declared_strip_env_still_composes() {
2771 let temp = tempdir().unwrap();
2772 let policy = CapabilityPolicy {
2773 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
2774 ..CapabilityPolicy::default()
2775 };
2776 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
2777
2778 let env =
2779 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2780
2781 assert!(!env.contains_key("MYCOMPANY_SECRET"));
2782 }
2783
2784 #[test]
2788 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
2789 let extra = vec!["*_SERVICE_SECRET".to_string()];
2790 let names = [
2791 "ANTHROPIC_API_KEY",
2792 "GITHUB_TOKEN",
2793 "GITHUB_TOKEN_2",
2794 "AWS_ACCESS_KEY_ID",
2795 "MY_AWS_KEY",
2796 "STRIPE_API_KEY",
2797 "KUBECONFIG",
2798 "MY_SERVICE_SECRET",
2799 "DATABASE_PASSWORD",
2800 "SLACK_BOT_TOKEN",
2801 "HOME",
2802 "TZ",
2803 ];
2804
2805 let mut env: BTreeMap<String, String> = names
2806 .iter()
2807 .map(|name| ((*name).to_string(), "value".to_string()))
2808 .collect();
2809 strip_credential_shaped_vars(&mut env, &extra);
2810
2811 for name in names {
2812 assert_eq!(
2813 credential_backstop_drops(name, &extra),
2814 !env.contains_key(name),
2815 "prediction and strip disagree about {name}"
2816 );
2817 assert_eq!(
2818 credential_backstop_match(name, &extra).is_some(),
2819 !env.contains_key(name),
2820 "match and strip disagree about {name}"
2821 );
2822 }
2823 }
2824
2825 #[test]
2826 fn credential_backstop_match_attributes_a_builtin_pattern() {
2827 assert_eq!(
2828 credential_backstop_match("AWS_REGION", &[]),
2829 Some(BackstopMatch {
2830 pattern: "AWS_*".to_string(),
2831 source: BackstopPatternSource::Builtin,
2832 })
2833 );
2834 assert_eq!(
2835 credential_backstop_match("GITHUB_TOKEN", &[]),
2836 Some(BackstopMatch {
2837 pattern: "GITHUB_TOKEN".to_string(),
2838 source: BackstopPatternSource::Builtin,
2839 })
2840 );
2841 }
2842
2843 #[test]
2844 fn credential_backstop_match_attributes_a_strip_env_pattern() {
2845 assert_eq!(
2846 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
2847 Some(BackstopMatch {
2848 pattern: "*_SERVICE_SECRET".to_string(),
2849 source: BackstopPatternSource::StripEnv,
2850 })
2851 );
2852 }
2853
2854 #[test]
2857 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
2858 assert_eq!(
2859 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
2860 Some(BackstopMatch {
2861 pattern: "*_API_KEY".to_string(),
2862 source: BackstopPatternSource::Builtin,
2863 })
2864 );
2865 }
2866
2867 #[test]
2868 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
2869 assert_eq!(credential_backstop_match("TZ", &[]), None);
2870 assert_eq!(
2871 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
2872 None
2873 );
2874 }
2875
2876 #[test]
2877 fn env_name_matches_pattern_trailing_wildcard() {
2878 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
2879 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
2880 }
2881
2882 #[test]
2883 fn env_name_matches_pattern_leading_wildcard() {
2884 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
2885 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
2886 }
2887
2888 #[test]
2889 fn env_name_matches_pattern_exact_match() {
2890 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
2891 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
2892 }
2893
2894 #[test]
2895 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
2896 let yaml = shell_tool_manifest_yaml("bash");
2897 serde_norway::from_str::<serde_norway::Value>(&yaml)
2898 .expect("bash manifest must be valid YAML");
2899 }
2900
2901 #[test]
2902 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
2903 let yaml = shell_tool_manifest_yaml("curl");
2904 serde_norway::from_str::<serde_norway::Value>(&yaml)
2905 .expect("curl manifest must be valid YAML — embedded quotes break YAML parsing");
2906 }
2907
2908 #[test]
2909 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
2910 let yaml = shell_tool_manifest_yaml("curl");
2911 let value: serde_norway::Value = serde_norway::from_str(&yaml).unwrap();
2912 assert_eq!(
2913 value.get("runtime").and_then(|v| v.as_str()),
2914 Some("tool"),
2915 "non-interpreter manifest must have runtime: tool so inventory picks it up"
2916 );
2917 }
2918
2919 #[test]
2922 fn a_direct_binary_description_states_the_argument_rule_and_nothing_else() {
2923 for binary in ["curl", "git", "jq", "python3"] {
2924 let yaml = shell_tool_manifest_yaml(binary);
2925 let value: serde_norway::Value = serde_norway::from_str(&yaml)
2926 .unwrap_or_else(|e| panic!("{binary} manifest must be valid YAML: {e}"));
2927 let description = value
2928 .get("description")
2929 .and_then(|v| v.as_str())
2930 .expect("the manifest has a description");
2931 assert_eq!(
2932 description,
2933 format!(
2934 "Run {binary} in the capsule workdir. The `command` field holds {binary}'s \
2935 arguments and not the binary name. It is split into arguments at whitespace; \
2936 single or double quotes group words, and a backslash escapes the next \
2937 character. No shell runs it, so nothing in it is expanded."
2938 )
2939 );
2940 assert!(!description.contains("example.com"), "{description}");
2941 assert!(!description.contains(" -s "), "{description}");
2942 assert_eq!(yaml, shell_tool_manifest_yaml(binary));
2943 }
2944 }
2945
2946 #[test]
2949 fn an_abandoned_command_demotes_before_its_grace() {
2950 an_abandoned_command_demotes_under(Some(Duration::from_secs(60)));
2951 }
2952
2953 #[test]
2955 fn an_abandoned_command_demotes_with_no_grace() {
2956 an_abandoned_command_demotes_under(None);
2957 }
2958
2959 fn an_abandoned_command_demotes_under(grace: Option<Duration>) {
2960 use std::sync::Arc;
2961
2962 use crate::{
2963 detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
2964 origin::{TaskOrigin, TaskProvenance},
2965 };
2966
2967 let temp = tempdir().unwrap();
2968 let runtime = tempdir().unwrap();
2969 let policy = CapabilityPolicy {
2970 shell_allow: vec!["bash".to_string()],
2971 ..CapabilityPolicy::default()
2972 };
2973 let (registry, _reports) = DetachedRegistry::new();
2974 let abandon = AbandonSignal::new();
2975 let raiser = {
2976 let abandon = abandon.clone();
2977 std::thread::spawn(move || {
2978 std::thread::sleep(Duration::from_millis(200));
2979 abandon.raise();
2980 Instant::now()
2981 })
2982 };
2983
2984 let started = Instant::now();
2985 let outcome = run_shell(
2986 "bash",
2987 &["-c", "sleep 5"],
2990 &[],
2991 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2992 &policy,
2993 &ShellEnforcement::environment_only()
2994 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2995 Some(DetachPolicy {
2996 grace,
2997 delivered: true,
2998 registry: Arc::clone(®istry),
2999 command: "sleep 5".to_string(),
3000 context_id: "ctx_abandon_test".to_string(),
3001 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3002 abandoned: Some(abandon),
3003 canceled: None,
3004 }),
3005 )
3006 .expect("a declared binary runs");
3007 let returned = Instant::now();
3008 let elapsed = returned - started;
3009 let raised = raiser.join().unwrap();
3010
3011 let ShellOutcome::Detached(info) = outcome else {
3012 panic!("an abandoned command must demote, got {outcome:?}");
3013 };
3014 assert!(
3015 elapsed < Duration::from_secs(2),
3016 "demotion waited {elapsed:?}, as if the command had to finish first"
3017 );
3018 assert!(
3019 returned.saturating_duration_since(raised) < Duration::from_secs(1),
3020 "demotion came {:?} after the abandon",
3021 returned.saturating_duration_since(raised)
3022 );
3023 assert_eq!(
3024 info.grace_ms,
3025 grace.map(|grace| u64::try_from(grace.as_millis()).unwrap())
3026 );
3027 assert!(
3028 registry
3029 .outstanding()
3030 .iter()
3031 .any(|work| work.work_id == info.work_id),
3032 "the demoted command is registered like one that outran its grace"
3033 );
3034 }
3035
3036 fn cancel_test_policy(
3038 registry: &std::sync::Arc<crate::detached::DetachedRegistry>,
3039 command: &str,
3040 canceled: crate::cancel::CancelSignal,
3041 grace: Option<Duration>,
3042 ) -> crate::detached::DetachPolicy {
3043 use crate::origin::{TaskOrigin, TaskProvenance};
3044 crate::detached::DetachPolicy {
3045 grace,
3046 delivered: true,
3047 registry: std::sync::Arc::clone(registry),
3048 command: command.to_string(),
3049 context_id: "ctx_cancel_test".to_string(),
3050 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3051 abandoned: None,
3052 canceled: Some(canceled),
3053 }
3054 }
3055
3056 #[test]
3059 fn a_canceled_task_demotes_its_foreground_command() {
3060 a_canceled_task_demotes_its_foreground_command_under(Some(Duration::from_secs(60)));
3061 }
3062
3063 #[test]
3065 fn a_canceled_task_demotes_its_foreground_command_with_no_grace() {
3066 a_canceled_task_demotes_its_foreground_command_under(None);
3067 }
3068
3069 fn a_canceled_task_demotes_its_foreground_command_under(grace: Option<Duration>) {
3070 use crate::{
3071 cancel::{CancelSignal, PHASE_SHELL},
3072 detached::{DetachedRegistry, KillRunningOnDrop},
3073 };
3074
3075 let temp = tempdir().unwrap();
3076 let runtime = tempdir().unwrap();
3077 let policy = CapabilityPolicy {
3078 shell_allow: vec!["bash".to_string()],
3079 ..CapabilityPolicy::default()
3080 };
3081 let (registry, _reports) = DetachedRegistry::new();
3082 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
3083 let cancel = CancelSignal::new();
3084 let raiser = {
3085 let cancel = cancel.clone();
3086 std::thread::spawn(move || {
3087 std::thread::sleep(Duration::from_millis(200));
3088 cancel.cancel();
3089 Instant::now()
3090 })
3091 };
3092
3093 let started = Instant::now();
3094 let outcome = run_shell(
3095 "bash",
3096 &["-c", "sleep 30"],
3097 &[],
3098 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3099 &policy,
3100 &ShellEnforcement::environment_only()
3101 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3102 Some(cancel_test_policy(
3103 ®istry,
3104 "sleep 30",
3105 cancel.clone(),
3106 grace,
3107 )),
3108 )
3109 .expect("a declared binary runs");
3110 let returned = Instant::now();
3111 let elapsed = returned - started;
3112 let raised = raiser.join().unwrap();
3113
3114 let ShellOutcome::Detached(info) = outcome else {
3115 panic!("a canceled task's command must demote, got {outcome:?}");
3116 };
3117 assert!(
3118 elapsed < Duration::from_secs(2),
3119 "demotion waited {elapsed:?}, as if the command had to finish first"
3120 );
3121 assert!(
3122 returned.saturating_duration_since(raised) < Duration::from_secs(1),
3123 "demotion came {:?} after the cancel",
3124 returned.saturating_duration_since(raised)
3125 );
3126 assert!(
3127 registry
3128 .outstanding()
3129 .iter()
3130 .any(|work| work.work_id == info.work_id),
3131 "the demoted command is registered, so the cancel residue names it"
3132 );
3133 assert_eq!(cancel.phase(), PHASE_SHELL);
3134 }
3135
3136 #[test]
3139 fn an_earlier_claim_keeps_the_phase_over_the_shell() {
3140 use crate::{
3141 cancel::{CancelSignal, PHASE_INFERENCE},
3142 detached::{DetachedRegistry, KillRunningOnDrop},
3143 };
3144
3145 let temp = tempdir().unwrap();
3146 let runtime = tempdir().unwrap();
3147 let policy = CapabilityPolicy {
3148 shell_allow: vec!["bash".to_string()],
3149 ..CapabilityPolicy::default()
3150 };
3151 let (registry, _reports) = DetachedRegistry::new();
3152 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
3153 let cancel = CancelSignal::new();
3154 cancel.note_phase(PHASE_INFERENCE);
3155 let raiser = {
3156 let cancel = cancel.clone();
3157 std::thread::spawn(move || {
3158 std::thread::sleep(Duration::from_millis(200));
3159 cancel.cancel();
3160 })
3161 };
3162
3163 let outcome = run_shell(
3164 "bash",
3165 &["-c", "sleep 30"],
3166 &[],
3167 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3168 &policy,
3169 &ShellEnforcement::environment_only()
3170 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3171 Some(cancel_test_policy(
3172 ®istry,
3173 "sleep 30",
3174 cancel.clone(),
3175 Some(Duration::from_secs(60)),
3176 )),
3177 )
3178 .expect("a declared binary runs");
3179 raiser.join().unwrap();
3180
3181 assert!(matches!(outcome, ShellOutcome::Detached(_)), "{outcome:?}");
3182 assert_eq!(cancel.phase(), PHASE_INFERENCE);
3183 }
3184
3185 #[test]
3187 fn a_canceled_task_starts_no_command() {
3188 a_canceled_task_starts_no_command_under(Some(Duration::from_secs(60)));
3189 }
3190
3191 #[test]
3193 fn a_canceled_task_starts_no_command_with_no_grace() {
3194 a_canceled_task_starts_no_command_under(None);
3195 }
3196
3197 fn a_canceled_task_starts_no_command_under(grace: Option<Duration>) {
3198 use crate::{cancel::CancelSignal, detached::DetachedRegistry};
3199
3200 let temp = tempdir().unwrap();
3201 let runtime = tempdir().unwrap();
3202 let policy = CapabilityPolicy {
3203 shell_allow: vec!["bash".to_string()],
3204 ..CapabilityPolicy::default()
3205 };
3206 let (registry, _reports) = DetachedRegistry::new();
3207 let cancel = CancelSignal::new();
3208 cancel.cancel();
3209
3210 let result = run_shell(
3211 "bash",
3212 &["-c", "touch started"],
3213 &[],
3214 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3215 &policy,
3216 &ShellEnforcement::environment_only()
3217 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3218 Some(cancel_test_policy(
3219 ®istry,
3220 "touch started",
3221 cancel.clone(),
3222 grace,
3223 )),
3224 );
3225
3226 match result {
3227 Err(ShellExecError::Failed(message)) => assert_eq!(message, CANCELED_BEFORE_SPAWN),
3228 other => panic!("a canceled task's call must be refused, got {other:?}"),
3229 }
3230 assert!(!temp.path().join("started").exists(), "nothing was spawned");
3231 assert!(registry.outstanding().is_empty(), "nothing was registered");
3232 }
3233
3234 fn run_until_stopped(
3237 workdir: &std::path::Path,
3238 runtime: &std::path::Path,
3239 script: &str,
3240 stop: ForegroundStop,
3241 ) -> ForegroundRun {
3242 let mut enforcement = ShellEnforcement::environment_only()
3243 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime), None);
3244 enforcement.resource_limits.max_processes = 1 << 20;
3248 run_shell_until_stopped(
3249 "bash",
3250 &["-c", script],
3251 &[],
3252 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
3253 &CapabilityPolicy {
3254 shell_allow: vec!["bash".to_string()],
3255 ..CapabilityPolicy::default()
3256 },
3257 &enforcement,
3258 stop,
3259 )
3260 .expect("a declared binary runs")
3261 }
3262
3263 const STOPPABLE_SCRIPT: &str = "sleep 300 & echo $! > sleeper.pid; echo started; sleep 300";
3264
3265 #[test]
3268 fn a_foreground_stop_kills_the_group_on_cancel() {
3269 let temp = tempdir().unwrap();
3270 let runtime = tempdir().unwrap();
3271 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3272 let cancel = crate::cancel::CancelSignal::new();
3273 let raiser = {
3274 let cancel = cancel.clone();
3275 let pid_file = temp.path().join("sleeper.pid");
3276 std::thread::spawn(move || {
3277 while !pid_file.exists() {
3278 std::thread::sleep(Duration::from_millis(10));
3279 }
3280 std::thread::sleep(Duration::from_millis(100));
3281 cancel.cancel();
3282 Instant::now()
3283 })
3284 };
3285
3286 let run = run_until_stopped(
3287 temp.path(),
3288 runtime.path(),
3289 STOPPABLE_SCRIPT,
3290 ForegroundStop {
3291 canceled: cancel,
3292 timeout: Duration::from_secs(60),
3293 },
3294 );
3295 let raised = raiser.join().unwrap();
3296
3297 assert!(
3298 raised.elapsed() < Duration::from_secs(1),
3299 "the call returned {:?} after the cancel",
3300 raised.elapsed()
3301 );
3302 assert_eq!(run.stopped, Some(ForegroundStopReason::Canceled));
3303 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
3304 assert_eq!(run.result.resource_limit_hit, None);
3305 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3306 assert!(
3307 process_dead_within(sleeper, Duration::from_secs(1)),
3308 "the background sleeper {sleeper} outlived the stop"
3309 );
3310 }
3311
3312 #[test]
3314 fn a_foreground_stop_kills_the_group_at_the_timeout() {
3315 let temp = tempdir().unwrap();
3316 let runtime = tempdir().unwrap();
3317 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3318
3319 let started = Instant::now();
3320 let run = run_until_stopped(
3321 temp.path(),
3322 runtime.path(),
3323 STOPPABLE_SCRIPT,
3324 ForegroundStop {
3325 canceled: crate::cancel::CancelSignal::new(),
3326 timeout: Duration::from_secs(1),
3327 },
3328 );
3329 let elapsed = started.elapsed();
3330
3331 assert!(
3332 elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(2),
3333 "the call returned after {elapsed:?} for a 1 s timeout"
3334 );
3335 assert_eq!(run.stopped, Some(ForegroundStopReason::TimedOut));
3336 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
3337 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3338 assert!(
3339 process_dead_within(sleeper, Duration::from_secs(1)),
3340 "the background sleeper {sleeper} outlived the timeout"
3341 );
3342 }
3343
3344 #[test]
3346 fn a_foreground_stop_leaves_a_finished_command_alone() {
3347 let temp = tempdir().unwrap();
3348 let runtime = tempdir().unwrap();
3349 let run = run_until_stopped(
3350 temp.path(),
3351 runtime.path(),
3352 "echo out; echo err >&2; exit 3",
3353 ForegroundStop {
3354 canceled: crate::cancel::CancelSignal::new(),
3355 timeout: Duration::from_secs(60),
3356 },
3357 );
3358 assert_eq!(run.stopped, None);
3359 assert_eq!(run.result.exit_code, 3);
3360 assert_eq!(run.result.stdout, "out\n");
3361 assert_eq!(run.result.stderr, "err\n");
3362 }
3363
3364 fn returns_within_30_s<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
3366 let (sender, receiver) = std::sync::mpsc::channel();
3367 std::thread::spawn(move || {
3368 let _ = sender.send(run());
3369 });
3370 receiver
3371 .recv_timeout(Duration::from_secs(30))
3372 .unwrap_or_else(|_| panic!("the shell call did not return within 30 s"))
3373 }
3374
3375 #[test]
3378 fn a_background_holder_does_not_hold_a_graced_call() {
3379 use std::sync::Arc;
3380
3381 use crate::{
3382 detached::{DetachPolicy, DetachedRegistry},
3383 origin::{TaskOrigin, TaskProvenance},
3384 };
3385
3386 let temp = tempdir().unwrap();
3387 let runtime = tempdir().unwrap();
3388 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3389 let workdir = temp.path().to_path_buf();
3390 let runtime_base = runtime.path().to_path_buf();
3391 let script = "sleep 300 & echo $! > sleeper.pid; echo started";
3392
3393 let started = Instant::now();
3394 let outcome = returns_within_30_s(move || {
3395 let (registry, _reports) = DetachedRegistry::new();
3396 run_shell(
3397 "bash",
3398 &["-c", script],
3399 &[],
3400 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3401 &CapabilityPolicy {
3402 shell_allow: vec!["bash".to_string()],
3403 ..CapabilityPolicy::default()
3404 },
3405 &ShellEnforcement::environment_only()
3406 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3407 Some(DetachPolicy {
3408 grace: Some(Duration::from_secs(5)),
3409 delivered: true,
3410 registry: Arc::clone(®istry),
3411 command: script.to_string(),
3412 context_id: "ctx_holder_test".to_string(),
3413 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3414 abandoned: None,
3415 canceled: None,
3416 }),
3417 )
3418 .expect("a declared binary runs")
3419 });
3420 let elapsed = started.elapsed();
3421
3422 let ShellOutcome::Finished(result) = outcome else {
3423 panic!("a command that exits at once must finish, got {outcome:?}");
3424 };
3425 assert!(
3426 elapsed < Duration::from_secs(5),
3427 "the call took {elapsed:?}, past its 5 s grace"
3428 );
3429 assert!(result.stdout.contains("started"), "{result:?}");
3430 assert_eq!(result.exit_code, 0, "{result:?}");
3431 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3432 assert!(
3433 process_dead_within(sleeper, Duration::from_secs(2)),
3434 "the background sleeper {sleeper} is still running"
3435 );
3436 }
3437
3438 #[test]
3441 fn a_background_holder_does_not_hold_an_ungraced_call() {
3442 let temp = tempdir().unwrap();
3443 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3444 let workdir = temp.path().to_path_buf();
3445
3446 let started = Instant::now();
3447 let result = returns_within_30_s(move || {
3448 execute_shell(
3449 "bash",
3450 &["-c", "sleep 300 & echo $! > sleeper.pid; echo started"],
3451 &[],
3452 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3453 &CapabilityPolicy {
3454 shell_allow: vec!["bash".to_string()],
3455 ..CapabilityPolicy::default()
3456 },
3457 &ShellEnforcement::environment_only().with_test_session_dirs(),
3458 )
3459 .expect("a declared binary runs")
3460 });
3461 let elapsed = started.elapsed();
3462
3463 assert!(
3464 elapsed < Duration::from_secs(10),
3465 "the call took {elapsed:?}"
3466 );
3467 assert!(result.stdout.contains("started"), "{result:?}");
3468 assert_eq!(result.exit_code, 0, "{result:?}");
3469 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3470 assert!(
3471 process_dead_within(sleeper, Duration::from_secs(2)),
3472 "the background sleeper {sleeper} is still running"
3473 );
3474 assert_eq!(result.output_holders, OutputHolders::Killed);
3475 }
3476
3477 #[test]
3480 fn output_written_just_after_exit_is_kept() {
3481 use std::sync::Arc;
3482
3483 use crate::{
3484 detached::{DetachPolicy, DetachedRegistry},
3485 origin::{TaskOrigin, TaskProvenance},
3486 };
3487
3488 let temp = tempdir().unwrap();
3489 let runtime = tempdir().unwrap();
3490 let (registry, _reports) = DetachedRegistry::new();
3491 let script = "(sleep 0.3; echo late) & echo early";
3492
3493 let outcome = run_shell(
3494 "bash",
3495 &["-c", script],
3496 &[],
3497 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3498 &CapabilityPolicy {
3499 shell_allow: vec!["bash".to_string()],
3500 ..CapabilityPolicy::default()
3501 },
3502 &ShellEnforcement::environment_only()
3503 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3504 Some(DetachPolicy {
3505 grace: Some(Duration::from_secs(5)),
3506 delivered: true,
3507 registry: Arc::clone(®istry),
3508 command: script.to_string(),
3509 context_id: "ctx_late_output_test".to_string(),
3510 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3511 abandoned: None,
3512 canceled: None,
3513 }),
3514 )
3515 .expect("a declared binary runs");
3516
3517 let ShellOutcome::Finished(result) = outcome else {
3518 panic!("a command that exits at once must finish, got {outcome:?}");
3519 };
3520 assert!(result.stdout.contains("early"), "{result:?}");
3521 assert!(result.stdout.contains("late"), "{result:?}");
3522 assert_eq!(result.output_holders, OutputHolders::Closed);
3523 assert!(OutputHolders::Closed.note().is_none());
3524 }
3525
3526 #[cfg(target_os = "linux")]
3529 #[test]
3530 fn a_holder_outside_the_process_group_is_reported_not_waited_for() {
3531 use std::sync::Arc;
3532
3533 use crate::{
3534 detached::{DetachPolicy, DetachedRegistry},
3535 origin::{TaskOrigin, TaskProvenance},
3536 };
3537
3538 let temp = tempdir().unwrap();
3539 let runtime = tempdir().unwrap();
3540 let pid_file = temp.path().join("escaped.pid");
3541 let escaped_guard = KillPidFileOnDrop(pid_file.clone());
3542 let workdir = temp.path().to_path_buf();
3543 let runtime_base = runtime.path().to_path_buf();
3544 let script = "setsid sleep 300 & echo $! > escaped.pid; echo started";
3545
3546 let started = Instant::now();
3547 let outcome = returns_within_30_s(move || {
3548 let (registry, _reports) = DetachedRegistry::new();
3549 run_shell(
3550 "bash",
3551 &["-c", script],
3552 &[],
3553 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3554 &CapabilityPolicy {
3555 shell_allow: vec!["bash".to_string()],
3556 ..CapabilityPolicy::default()
3557 },
3558 &ShellEnforcement::environment_only()
3559 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3560 Some(DetachPolicy {
3561 grace: Some(Duration::from_secs(5)),
3562 delivered: true,
3563 registry: Arc::clone(®istry),
3564 command: script.to_string(),
3565 context_id: "ctx_escaped_test".to_string(),
3566 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3567 abandoned: None,
3568 canceled: None,
3569 }),
3570 )
3571 .expect("a declared binary runs")
3572 });
3573 let elapsed = started.elapsed();
3574
3575 let ShellOutcome::Finished(result) = outcome else {
3576 panic!("a command that exits at once must finish, got {outcome:?}");
3577 };
3578 let bound = Duration::from_secs(5) + LEFTOVER_KILL_WAIT + Duration::from_secs(2);
3579 assert!(elapsed < bound, "the call took {elapsed:?}, past {bound:?}");
3580 assert_eq!(result.stdout.trim(), "started", "{result:?}");
3581 assert_eq!(result.output_holders, OutputHolders::Escaped);
3582 let escaped = read_pid(&pid_file);
3583 assert!(
3584 !process_dead_within(escaped, Duration::ZERO),
3585 "a holder outside the process group is not the call's to kill"
3586 );
3587 drop(escaped_guard);
3588 assert!(process_dead_within(escaped, Duration::from_secs(2)));
3589 }
3590
3591 #[test]
3592 fn a_shared_window_attributes_no_cgroup_delta() {
3593 assert_eq!(
3594 attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
3595 Some("cgroup_memory_bytes"),
3596 "a call alone in the scope owns its delta"
3597 );
3598 assert_eq!(
3599 attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
3600 None,
3601 "a delta from a shared window could be a sibling's"
3602 );
3603 assert_eq!(
3604 attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
3605 .as_deref(),
3606 Some("cpu_seconds"),
3607 "the signal is per-process evidence and needs no exclusive window"
3608 );
3609 assert_eq!(attribute_resource_limit(None, None, true), None);
3610 }
3611
3612 #[test]
3616 fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
3617 let workdir = tempfile::tempdir().unwrap();
3618 let root = WorkdirRoot::open(workdir.path()).unwrap();
3619 std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
3620 let policy = CapabilityPolicy {
3621 shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3622 env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3623 ..CapabilityPolicy::default()
3624 };
3625 let overrides = [(
3626 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3627 "/override".to_string(),
3628 )];
3629
3630 let env = build_shell_env(
3631 &policy,
3632 &overrides,
3633 Some(root.path()),
3634 Some(std::path::Path::new("/runtime/dir")),
3635 None,
3636 None,
3637 )
3638 .unwrap();
3639 assert_eq!(
3640 env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
3641 .map(String::as_str),
3642 Some("/runtime/dir")
3643 );
3644
3645 let without =
3646 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3647 assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3648 assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3649 }
3650
3651 #[test]
3655 fn build_unconfined_env_inherits_everything_but_the_lineage_names() {
3656 let get = |env: &BTreeMap<OsString, OsString>, name: &str| {
3657 env.get(std::ffi::OsStr::new(name))
3658 .map(|value| value.to_string_lossy().into_owned())
3659 };
3660 let removed: Vec<&str> = LINEAGE_ENV
3661 .iter()
3662 .copied()
3663 .chain([
3664 crate::formation::FORMATION_PEERS_ENV,
3665 crate::formation_credentials::FORMATION_CHANNEL_ENV,
3666 crate::lifeline::FORMATION_LIFELINE_ENV,
3667 ARTIFACT_CONFIG_ENV,
3668 ])
3669 .collect();
3670 let host: BTreeMap<OsString, OsString> = removed
3673 .iter()
3674 .map(|name| (*name, "host-value"))
3675 .chain([
3676 ("GITHUB_TOKEN", "unconfined-sentinel"),
3677 ("MURMUR_TEST_UNCONFINED", "present"),
3678 ("MURMUR_TEST_STRIPPED", "gone"),
3679 ("HOME", "/home/operator"),
3680 ("TMPDIR", "/var/tmp/operator"),
3681 (crate::runtime_dir::RUNTIME_DIR_ENV, "/host/runtime"),
3682 (crate::records_dir::RECORDS_DIR_ENV, "/host/records"),
3683 ])
3684 .map(|(name, value)| (OsString::from(name), OsString::from(value)))
3685 .collect();
3686 let policy = CapabilityPolicy {
3687 shell_strip_env: vec!["MURMUR_TEST_STRIP*".to_string()],
3688 ..CapabilityPolicy::default()
3689 };
3690 let overrides = [
3691 (
3692 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3693 "/override".to_string(),
3694 ),
3695 (
3696 crate::delegation::SPAWNER_ENV.to_string(),
3697 "override".to_string(),
3698 ),
3699 ("GUEST_SET".to_string(), "yes".to_string()),
3700 ];
3701
3702 let env = build_unconfined_env(
3703 &policy,
3704 host.clone(),
3705 &overrides,
3706 Some(std::path::Path::new("/session/runtime")),
3707 Some(std::path::Path::new("/session/records")),
3708 );
3709 for name in &removed {
3710 assert_eq!(
3711 get(&env, name),
3712 None,
3713 "{name} reached an unconfined command"
3714 );
3715 }
3716 assert_eq!(
3717 get(&env, "GITHUB_TOKEN").as_deref(),
3718 Some("unconfined-sentinel")
3719 );
3720 assert_eq!(
3721 get(&env, "MURMUR_TEST_UNCONFINED").as_deref(),
3722 Some("present")
3723 );
3724 assert_eq!(get(&env, "MURMUR_TEST_STRIPPED"), None);
3725 assert_eq!(get(&env, "HOME").as_deref(), Some("/home/operator"));
3726 assert_eq!(get(&env, "TMPDIR").as_deref(), Some("/var/tmp/operator"));
3727 assert_eq!(get(&env, "GUEST_SET").as_deref(), Some("yes"));
3728 assert_eq!(
3729 get(&env, crate::runtime_dir::RUNTIME_DIR_ENV).as_deref(),
3730 Some("/session/runtime")
3731 );
3732 assert_eq!(
3733 get(&env, crate::records_dir::RECORDS_DIR_ENV).as_deref(),
3734 Some("/session/records")
3735 );
3736 let without = build_unconfined_env(&policy, host, &overrides, None, None);
3738 assert_eq!(get(&without, crate::runtime_dir::RUNTIME_DIR_ENV), None);
3739 assert_eq!(get(&without, crate::records_dir::RECORDS_DIR_ENV), None);
3740 }
3741
3742 #[test]
3746 fn subprocess_env_appends_the_pinned_bin_folder_last_on_every_tier() {
3747 use crate::pinned_programs::{PinnedSource, ResolvedPinnedProgram};
3748 let bin = std::path::PathBuf::from("/home/u/.murmur/programs/abc/bin");
3749 let program = |source: PinnedSource| ResolvedPinnedProgram {
3750 artifact: "local/pinned-tool".to_string(),
3751 program: "fixture-prog".to_string(),
3752 platform: "linux-x86_64".to_string(),
3753 source,
3754 };
3755 let policy = CapabilityPolicy {
3756 shell_pinned_programs: vec![program(PinnedSource::Pinned {
3757 path: bin.join("../files/fixture-prog"),
3758 bin_dir: bin.clone(),
3759 tree: bin.parent().unwrap().to_path_buf(),
3760 url: "http://127.0.0.1:1/f".to_string(),
3761 sha256: "abc".to_string(),
3762 })],
3763 ..CapabilityPolicy::default()
3764 };
3765 let contained =
3766 crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs();
3767 let unconfined = crate::sandbox::ShellEnforcement {
3768 tier: crate::sandbox::EnforcementTier::Unconfined,
3769 ..contained.clone()
3770 };
3771 let path_key = std::ffi::OsStr::new("PATH");
3772 for enforcement in [&contained, &unconfined] {
3773 let without = subprocess_env(&CapabilityPolicy::default(), &[], enforcement).unwrap();
3774 let with = subprocess_env(&policy, &[], enforcement).unwrap();
3775 let host = without.get(path_key).cloned().unwrap_or_default();
3776 let mut expected = host.clone();
3777 if !expected.is_empty() {
3778 expected.push(":");
3779 }
3780 expected.push(&bin);
3781 assert_eq!(
3782 with.get(path_key),
3783 Some(&expected),
3784 "{:?}",
3785 enforcement.tier
3786 );
3787 assert!(!host.to_string_lossy().contains(".murmur/programs"));
3788 }
3789
3790 let mut empty = BTreeMap::new();
3791 append_pinned_program_path(&mut empty, &policy).unwrap();
3792 assert_eq!(empty.get(path_key), Some(&bin.clone().into_os_string()));
3793
3794 let host_choice = CapabilityPolicy {
3795 shell_pinned_programs: vec![program(PinnedSource::Host {
3796 path: "/usr/bin/fixture-prog".into(),
3797 })],
3798 ..CapabilityPolicy::default()
3799 };
3800 let mut env = BTreeMap::from([(OsString::from("PATH"), OsString::from("/usr/bin"))]);
3801 append_pinned_program_path(&mut env, &host_choice).unwrap();
3802 assert_eq!(env.get(path_key), Some(&OsString::from("/usr/bin")));
3803 }
3804
3805 #[test]
3809 fn subprocess_env_follows_the_enforcement_tier() {
3810 let policy = CapabilityPolicy::default();
3811 let contained =
3812 crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs();
3813 let unconfined = crate::sandbox::ShellEnforcement {
3814 tier: crate::sandbox::EnforcementTier::Unconfined,
3815 ..contained.clone()
3816 };
3817 let session_home = contained.home_dir_path().unwrap().as_os_str().to_owned();
3818 let home = std::ffi::OsStr::new("HOME");
3819 assert_eq!(
3820 subprocess_env(&policy, &[], &contained).unwrap().get(home),
3821 Some(&session_home)
3822 );
3823 let unconfined_home = subprocess_env(&policy, &[], &unconfined)
3824 .unwrap()
3825 .get(home)
3826 .cloned();
3827 assert_eq!(unconfined_home, std::env::var_os("HOME"));
3828 assert_ne!(unconfined_home, Some(session_home));
3829 }
3830
3831 #[test]
3835 fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
3836 let workdir = tempfile::tempdir().unwrap();
3837 let root = WorkdirRoot::open(workdir.path()).unwrap();
3838 std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
3839 let policy = CapabilityPolicy {
3840 shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3841 env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3842 ..CapabilityPolicy::default()
3843 };
3844 let overrides = [(
3845 crate::records_dir::RECORDS_DIR_ENV.to_string(),
3846 "/override".to_string(),
3847 )];
3848
3849 let env = build_shell_env(
3850 &policy,
3851 &overrides,
3852 Some(root.path()),
3853 None,
3854 Some(std::path::Path::new("/records/dir")),
3855 None,
3856 )
3857 .unwrap();
3858 assert_eq!(
3859 env.get(crate::records_dir::RECORDS_DIR_ENV)
3860 .map(String::as_str),
3861 Some("/records/dir")
3862 );
3863
3864 let without =
3865 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3866 assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
3867 assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
3868 }
3869
3870 #[test]
3874 fn build_shell_env_drops_the_artifact_config_name_whatever_else_says() {
3875 let workdir = tempfile::tempdir().unwrap();
3876 let root = WorkdirRoot::open(workdir.path()).unwrap();
3877 let names = [ARTIFACT_CONFIG_ENV];
3878 for name in names {
3879 std::env::set_var(name, "/host/value");
3880 }
3881 let policy = CapabilityPolicy {
3882 shell_baseline_env: names.iter().map(|name| name.to_string()).collect(),
3883 env_allow: names.iter().map(|name| name.to_string()).collect(),
3884 ..CapabilityPolicy::default()
3885 };
3886 let overrides: Vec<(String, String)> = names
3887 .iter()
3888 .map(|name| (name.to_string(), "/override".to_string()))
3889 .collect();
3890
3891 let env =
3892 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3893 let declared = build_declared_env(&policy);
3894 for name in names {
3895 std::env::remove_var(name);
3896 }
3897 for name in names {
3898 assert!(!env.contains_key(name), "{name}: {env:?}");
3899 assert!(!declared.contains_key(name), "{name}: {declared:?}");
3900 }
3901 }
3902
3903 #[test]
3907 fn build_shell_env_owns_tmpdir_tmp_and_temp() {
3908 use crate::temp_dir::{NotMappedReason, TempMapping, TEMP_DIR_ENV};
3909
3910 let workdir = tempfile::tempdir().unwrap();
3911 let root = WorkdirRoot::open(workdir.path()).unwrap();
3912 let base = tempfile::tempdir().unwrap();
3913 let roots = crate::sandbox::test_runtime_dir(base.path());
3914 let folder = roots.temp.as_ref().unwrap().path().to_path_buf();
3915 let host_tmp = std::env::temp_dir().join("murmur-host-tmp");
3918 std::fs::create_dir_all(&host_tmp).unwrap();
3919 let saved: Vec<_> = TEMP_DIR_ENV
3920 .iter()
3921 .map(|name| (*name, std::env::var_os(name)))
3922 .collect();
3923 for name in TEMP_DIR_ENV {
3924 std::env::set_var(name, &host_tmp);
3925 }
3926 let policy = CapabilityPolicy {
3927 shell_baseline_env: TEMP_DIR_ENV.iter().map(|name| name.to_string()).collect(),
3928 ..CapabilityPolicy::default()
3929 };
3930 let overrides: Vec<(String, String)> = TEMP_DIR_ENV
3931 .iter()
3932 .map(|name| (name.to_string(), "/override".to_string()))
3933 .collect();
3934
3935 let held = |tier, mapping| {
3936 crate::sandbox::ShellEnforcement {
3937 tier,
3938 ..crate::sandbox::ShellEnforcement::environment_only()
3939 }
3940 .with_runtime_dir(roots.clone(), None)
3941 .with_temp_mapping(mapping)
3942 };
3943 let folder_text = folder.to_string_lossy().into_owned();
3944 let cases = [
3945 (
3946 held(
3947 crate::sandbox::EnforcementTier::KernelFull,
3948 TempMapping::MountNamespace,
3949 ),
3950 Some("/tmp".to_string()),
3951 ),
3952 (
3953 held(
3954 crate::sandbox::EnforcementTier::KernelSealed,
3955 TempMapping::ComposedRoot,
3956 ),
3957 Some("/tmp".to_string()),
3958 ),
3959 (
3960 held(
3961 crate::sandbox::EnforcementTier::KernelFull,
3962 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
3963 ),
3964 Some(folder_text.clone()),
3965 ),
3966 (
3967 held(
3968 crate::sandbox::EnforcementTier::KernelSeccompOnly,
3969 TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
3970 ),
3971 Some(folder_text.clone()),
3972 ),
3973 (
3974 held(
3975 crate::sandbox::EnforcementTier::KernelFull,
3976 TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
3977 ),
3978 Some(folder_text.clone()),
3979 ),
3980 (
3981 held(
3982 crate::sandbox::EnforcementTier::EnvironmentOnly,
3983 TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
3984 ),
3985 Some(folder_text.clone()),
3986 ),
3987 (crate::sandbox::ShellEnforcement::environment_only(), None),
3988 ];
3989 for (enforcement, expected) in cases {
3990 assert_eq!(
3991 enforcement.tmpdir_value(),
3992 expected,
3993 "{:?}",
3994 enforcement.tier
3995 );
3996 let env = build_shell_env(
3997 &policy,
3998 &overrides,
3999 Some(root.path()),
4000 None,
4001 None,
4002 enforcement.tmpdir_value().as_deref(),
4003 )
4004 .unwrap();
4005 for name in TEMP_DIR_ENV {
4006 assert_eq!(
4007 env.get(name),
4008 expected.as_ref(),
4009 "{name} on {:?}",
4010 enforcement.tier
4011 );
4012 }
4013 }
4014 for (name, value) in saved {
4015 match value {
4016 Some(value) => std::env::set_var(name, value),
4017 None => std::env::remove_var(name),
4018 }
4019 }
4020 }
4021
4022 #[test]
4024 fn the_env_baseline_names_no_temp_variable() {
4025 for name in crate::temp_dir::TEMP_DIR_ENV {
4026 assert!(!DEFAULT_ENV_BASELINE.contains(&name), "{name}");
4027 }
4028 }
4029}
4030
4031#[cfg(test)]
4033pub(crate) mod test_support {
4034 use std::path::{Path, PathBuf};
4035 use std::time::{Duration, Instant};
4036
4037 pub(crate) fn read_pid(pid_file: &Path) -> u32 {
4039 std::fs::read_to_string(pid_file)
4040 .expect("the command wrote its pid file")
4041 .trim()
4042 .parse()
4043 .expect("the pid file holds a pid")
4044 }
4045
4046 pub(crate) fn process_dead_within(pid: u32, timeout: Duration) -> bool {
4049 let deadline = Instant::now() + timeout;
4050 loop {
4051 if !crate::running::pid_is_alive(pid) {
4052 return true;
4053 }
4054 if Instant::now() >= deadline {
4055 return false;
4056 }
4057 std::thread::sleep(Duration::from_millis(20));
4058 }
4059 }
4060
4061 pub(crate) struct KillPidFileOnDrop(pub(crate) PathBuf);
4065
4066 impl Drop for KillPidFileOnDrop {
4067 fn drop(&mut self) {
4068 let Some(pid) = std::fs::read_to_string(&self.0)
4069 .ok()
4070 .and_then(|text| text.trim().parse::<libc::pid_t>().ok())
4071 .filter(|pid| *pid > 0)
4072 else {
4073 return;
4074 };
4075 if !process_dead_within(pid.unsigned_abs(), Duration::ZERO) {
4076 #[allow(unsafe_code)]
4079 unsafe {
4080 libc::kill(pid, libc::SIGKILL);
4081 }
4082 }
4083 }
4084 }
4085}