1use std::path::{Path, PathBuf};
31use std::sync::OnceLock;
32
33use serde::{Deserialize, Serialize};
34
35use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
36
37#[cfg(any(target_os = "macos", target_os = "linux"))]
38mod denials;
39#[cfg(any(target_os = "macos", target_os = "linux"))]
40pub mod procage;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Network {
46 Off,
48 Loopback,
51 On,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Mode {
58 Auto,
60 Required,
62 Off,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Config {
69 pub mode: Mode,
71 pub network: Network,
73 pub network_allow: Vec<Vec<String>>,
75 pub network_allow_prefix: Vec<Vec<String>>,
77 pub writable: Vec<String>,
79}
80
81impl Default for Config {
82 fn default() -> Self {
83 Self {
84 mode: Mode::Auto,
85 network: Network::Loopback,
86 network_allow: Vec::new(),
87 network_allow_prefix: Vec::new(),
88 writable: Vec::new(),
89 }
90 }
91}
92
93#[derive(Debug, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95struct Raw {
96 mode: Option<String>,
97 network: Option<String>,
98 #[serde(default)]
99 network_allow: Vec<Vec<String>>,
100 #[serde(default)]
101 network_allow_prefix: Vec<Vec<String>>,
102 #[serde(default)]
103 writable: Vec<String>,
104}
105
106pub fn parse(text: &str) -> Result<Config, String> {
108 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
109 let Some(v) = table.get("sandbox") else {
110 return Ok(Config::default());
111 };
112 let raw: Raw = v
113 .clone()
114 .try_into()
115 .map_err(|e| format!("[sandbox]: {e}"))?;
116 let mode = match raw.mode.as_deref() {
117 None | Some("auto") => Mode::Auto,
118 Some("required") => Mode::Required,
119 Some("off") => Mode::Off,
120 Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
121 };
122 let network = match raw.network.as_deref() {
123 None | Some("loopback") => Network::Loopback,
124 Some("off") => Network::Off,
125 Some("on") => Network::On,
126 Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
127 };
128 if let Some(why) = raw
129 .network_allow_prefix
130 .iter()
131 .find_map(|p| crate::tools::policy::prefix_refusal(p))
132 {
133 return Err(format!("[sandbox] network_allow_prefix: {why}"));
134 }
135 Ok(Config {
136 mode,
137 network,
138 network_allow: raw
139 .network_allow
140 .into_iter()
141 .filter(|p| !p.is_empty())
142 .collect(),
143 network_allow_prefix: raw.network_allow_prefix,
144 writable: raw.writable,
145 })
146}
147
148pub const HIDDEN: &[&str] = &[
150 ".ssh",
151 ".aws",
152 ".azure",
153 ".gnupg",
154 ".netrc",
155 ".docker",
156 ".kube",
157 ".npmrc",
158 ".pypirc",
159 ".git-credentials",
160 ".password-store",
161 ".config/gh",
162 ".config/gcloud",
163 ".config/hub",
164 ".cargo/credentials",
165 ".cargo/credentials.toml",
166 ".local/share/keyrings",
167 "Library/Keychains",
168];
169
170pub const CACHES: &[&str] = &[
173 ".cargo/registry",
174 ".cargo/git",
175 ".cargo/.package-cache",
176 ".cargo/.package-cache-mutate",
177 ".cargo/.global-cache",
178 ".cache",
179 ".npm",
180 ".pnpm-store",
181 ".yarn/berry/cache",
182 "go/pkg/mod",
183 "Library/Caches",
184];
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct Profile {
190 pub writable: Vec<PathBuf>,
192 pub hidden: Vec<PathBuf>,
194 pub read_only: Vec<PathBuf>,
197 pub network: Network,
199 pub tmp: PathBuf,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub caches: Vec<PathBuf>,
205 #[serde(default)]
208 pub unenforced: Unenforced,
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(deny_unknown_fields, default)]
217pub struct Unenforced {
218 pub trust_handoff: bool,
220 pub workspace_sockets: bool,
222 pub signal_scope: bool,
225 pub carve: bool,
228}
229
230fn canonical(p: &Path) -> PathBuf {
231 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
232}
233
234impl Profile {
235 #[must_use]
237 pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
238 let root = canonical(root);
239 let expand = |p: &str| -> Option<PathBuf> {
240 match (p.strip_prefix("~/"), home) {
241 (Some(rest), Some(h)) => Some(h.join(rest)),
242 (Some(_), None) => None,
243 (None, _) => Some(PathBuf::from(p)),
244 }
245 };
246 let home = home.map(canonical);
247 let home = home.as_deref();
248 let mut writable = vec![root.clone()];
249 writable.extend(
250 config
251 .writable
252 .iter()
253 .filter_map(|p| expand(p))
254 .map(|p| canonical(&p)),
255 );
256 let hidden = home
257 .into_iter()
258 .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
259 .collect();
260 let digest = {
261 use sha2::{Digest as _, Sha256};
262 let d = Sha256::digest(root.to_string_lossy().as_bytes());
263 d.iter()
264 .take(6)
265 .map(|b| format!("{b:02x}"))
266 .collect::<String>()
267 };
268 let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
270 let profile = Self {
271 writable,
272 hidden,
273 read_only: Vec::new(),
274 network: Network::Off,
275 tmp,
276 caches: home
277 .into_iter()
278 .flat_map(|h| CACHES.iter().map(move |c| h.join(c)))
279 .collect(),
280 unenforced: Unenforced::default(),
281 };
282 profile.with_network(config.network)
283 }
284
285 #[must_use]
288 pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
289 self.read_only
290 .extend(read_only.iter().map(|p| canonical(p)));
291 self.hidden.extend(hidden.iter().map(|p| canonical(p)));
292 self
293 }
294
295 #[must_use]
298 pub fn with_network(mut self, network: Network) -> Self {
299 self.network = network;
300 if network == Network::On {
301 self = self.fetching();
302 }
303 self
304 }
305
306 #[must_use]
309 pub fn fetching(mut self) -> Self {
310 for c in &self.caches {
311 if !self.writable.contains(c) {
312 self.writable.push(c.clone());
313 }
314 }
315 self
316 }
317
318 #[must_use]
323 pub fn private_env(&self) -> Vec<(String, String)> {
324 let tmp = self.tmp.to_string_lossy().into_owned();
325 let mut env: Vec<(String, String)> = ["TMPDIR", "TMP", "TEMP"]
326 .map(|k| (k.to_owned(), tmp.clone()))
327 .to_vec();
328 let fetching = self.caches.iter().all(|c| self.writable.contains(c));
329 if !fetching {
330 let cache = self.tmp.join("cache");
331 for (k, sub) in [
332 ("XDG_CACHE_HOME", ""),
333 ("UV_CACHE_DIR", "uv"),
334 ("GOCACHE", "go-build"),
335 ] {
336 let dir = if sub.is_empty() {
337 cache.clone()
338 } else {
339 cache.join(sub)
340 };
341 let _ = std::fs::create_dir_all(&dir);
342 env.push((k.to_owned(), dir.to_string_lossy().into_owned()));
343 }
344 }
345 env
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct Backend {
355 pub helper: Option<PathBuf>,
357}
358
359impl Backend {
360 #[must_use]
363 pub fn mechanism(&self) -> &'static str {
364 if cfg!(target_os = "macos") {
365 "Seatbelt"
366 } else {
367 "Landlock+seccomp"
368 }
369 }
370}
371
372pub const HELPER_ARG: &str = "__sandbox";
374
375pub fn backend() -> Result<Backend, String> {
377 static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
378 CACHED.get_or_init(detect).clone()
379}
380
381fn detect() -> Result<Backend, String> {
382 available()?;
383 Ok(Backend {
384 helper: helper_binary(),
385 })
386}
387
388pub fn available() -> Result<(), String> {
394 #[cfg(any(target_os = "macos", target_os = "linux"))]
395 return procage::available();
396 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
397 Err(format!("no sandbox for {}", std::env::consts::OS))
398}
399
400fn helper_binary() -> Option<PathBuf> {
403 std::env::var_os("SPECCODER_SANDBOX_HELPER")
404 .map(PathBuf::from)
405 .or_else(|| {
406 std::env::current_exe()
407 .ok()
408 .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
409 })
410}
411
412pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
419 #[cfg(any(target_os = "macos", target_os = "linux"))]
420 return procage::fit(profile);
421 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
422 {
423 let _ = profile;
424 Err(format!("no sandbox for {}", std::env::consts::OS))
425 }
426}
427
428pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
430 let helper = backend.helper.as_ref().ok_or_else(|| {
432 "servers start through the speccoder binary, which was not found (SPECCODER_SANDBOX_HELPER)"
433 .to_owned()
434 })?;
435 let mut out = vec![
436 helper.to_string_lossy().into_owned(),
437 HELPER_ARG.to_owned(),
438 serde_json::to_string(p).map_err(|e| e.to_string())?,
439 "--".to_owned(),
440 ];
441 out.extend_from_slice(argv);
442 Ok(out)
443}
444
445#[derive(Debug, Clone)]
447pub struct Sandboxed {
448 pub profile: Profile,
450 pub backend: Backend,
452 pub network_allow: Vec<Vec<String>>,
454 pub network_allow_prefix: Vec<Vec<String>>,
456}
457
458impl Sandboxed {
459 #[must_use]
462 pub fn network_for(&self, argv: &[String]) -> Network {
463 if self.network_allow.iter().any(|p| p.as_slice() == argv)
464 || self
465 .network_allow_prefix
466 .iter()
467 .any(|p| crate::tools::policy::prefix_matches(p, argv))
468 {
469 Network::On
470 } else {
471 self.profile.network
472 }
473 }
474
475 #[must_use]
477 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
478 let profile = self.profile.clone().with_network(network);
479 let failed = |e: String| Outcome {
480 exit_code: None,
481 timed_out: false,
482 spawn_error: Some(e),
483 started: std::time::SystemTime::now(),
484 duration: std::time::Duration::ZERO,
485 stdout: crate::verify::process::Captured::default(),
486 stderr: crate::verify::process::Captured::default(),
487 sandbox: Default::default(),
488 };
489 if let Some(program) = inv.argv.first()
492 && !resolvable(program, &inv.cwd, &inv.env)
493 {
494 return failed(format!(
495 "could not start `{program}`: No such file or directory"
496 ));
497 }
498 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
499 return failed(format!("the sandbox's temporary directory: {e}"));
500 }
501 let mut env: Vec<(String, String)> = inv
502 .env
503 .iter()
504 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
505 .cloned()
506 .collect();
507 for (k, v) in profile.private_env() {
509 if !env.iter().any(|(e, _)| *e == k) {
510 env.push((k, v));
511 }
512 }
513 #[cfg(any(target_os = "macos", target_os = "linux"))]
514 return procage::run(&profile, &Invocation { env, ..inv.clone() });
515 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
516 {
517 let _ = env;
518 failed(format!("no sandbox for {}", std::env::consts::OS))
519 }
520 }
521}
522
523#[must_use]
530pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
531 let candidate = |p: PathBuf| {
532 executable_file(&p)
533 .then(|| std::fs::canonicalize(&p).ok())
534 .flatten()
535 };
536 if program.is_empty() {
537 return None;
538 }
539 if program.contains('/') {
540 return candidate(cwd.join(program));
541 }
542 let path = env
543 .iter()
544 .find(|(k, _)| k == "PATH")
545 .map(|(_, v)| v.as_str());
546 path.unwrap_or("/usr/bin:/bin")
547 .split(':')
548 .filter(|d| !d.is_empty())
549 .find_map(|d| candidate(cwd.join(d).join(program)))
550}
551
552fn executable_file(p: &Path) -> bool {
554 #[cfg(unix)]
555 {
556 use std::os::unix::fs::PermissionsExt as _;
557 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
558 }
559 #[cfg(not(unix))]
560 {
561 p.is_file()
562 }
563}
564
565fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
567 resolve_executable(program, cwd, env).is_some()
568}
569
570impl CommandRunner for Sandboxed {
571 fn run(&self, inv: &Invocation) -> Outcome {
572 self.run_with_network(inv, self.network_for(&inv.argv))
573 }
574}
575
576pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
580 if config.mode == Mode::Off {
581 return Ok((
582 Runner::Plain,
583 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
584 ));
585 }
586 let home = std::env::var_os("HOME").map(PathBuf::from);
587 let profile = Profile::new(root, config, home.as_deref());
588 match backend().and_then(|backend| Ok((backend, fit(&profile)?))) {
591 Ok((backend, (profile, notice))) => Ok((
592 Runner::Sandboxed(Sandboxed {
593 profile,
594 backend,
595 network_allow: config.network_allow.clone(),
596 network_allow_prefix: config.network_allow_prefix.clone(),
597 }),
598 notice,
599 )),
600 Err(e) if config.mode == Mode::Required => Err(format!(
601 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
602 )),
603 Err(e) => Ok((
604 Runner::Plain,
605 Some(format!("commands run unsandboxed: {e}")),
606 )),
607 }
608}
609
610#[must_use]
614pub fn contained_at(root: &Path) -> (bool, String) {
615 let text = crate::verify::find_config(root)
616 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
617 .unwrap_or_default();
618 let allowed = crate::substrate::run_config::parse(&text).is_ok_and(|c| c.contained);
619 if !allowed {
620 return (
621 true,
622 "every command that is not a check asks ([approvals] contained = \"ask\")".to_owned(),
623 );
624 }
625 match runner_at(root).map(|(r, _)| r.confines()) {
626 Ok(Some(m)) => (
627 true,
628 format!(
629 "commands the sandbox confines run without asking ({m}) — [approvals] contained = \"ask\" to be asked"
630 ),
631 ),
632 Ok(None) | Err(_) => (
633 false,
634 "every command that is not a check asks: no sandbox confines them here".to_owned(),
635 ),
636 }
637}
638
639pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
641 let text = crate::verify::find_config(root)
642 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
643 .unwrap_or_default();
644 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
645 runner(root, &config)
646}
647
648impl Runner {
649 #[must_use]
653 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
654 match self {
655 Self::Sandboxed(mut s) => {
656 let carved = s.profile.clone().carve(read_only, hidden);
657 match fit(&carved) {
658 Ok((fitted, notice)) => {
659 s.profile = fitted;
660 (Self::Sandboxed(s), notice)
661 }
662 Err(e) => (
663 Self::Sandboxed({
664 s.profile.unenforced.carve = true;
665 s
666 }),
667 Some(format!(
668 "the sandbox cannot carve here ({e}): commands can write `.git` and read ignored secrets in place; the run stops if `.git` changes"
669 )),
670 ),
671 }
672 }
673 Self::Plain => (Self::Plain, None),
674 }
675 }
676}
677
678pub type Wrapped = (Vec<String>, Option<Vec<(String, String)>>);
681
682#[derive(Debug, Clone)]
684pub enum Runner {
685 Sandboxed(Sandboxed),
687 Plain,
689}
690
691impl Runner {
692 #[must_use]
694 pub fn is_sandboxed(&self) -> bool {
695 matches!(self, Self::Sandboxed(_))
696 }
697
698 #[must_use]
703 pub fn confines(&self) -> Option<&'static str> {
704 match self {
705 Self::Sandboxed(s)
706 if s.profile.network != Network::On
707 && !s.profile.unenforced.signal_scope
708 && !s.profile.unenforced.carve =>
709 {
710 Some(s.backend.mechanism())
711 }
712 _ => None,
713 }
714 }
715
716 #[must_use]
721 pub fn with_trust_handoff_writable(self) -> Self {
722 match self {
723 Self::Sandboxed(mut s) => {
724 s.profile.unenforced.trust_handoff = true;
725 Self::Sandboxed(s)
726 }
727 Self::Plain => Self::Plain,
728 }
729 }
730
731 #[must_use]
733 pub fn with_writable(self, dir: &Path) -> Self {
734 match self {
735 Self::Sandboxed(mut s) => {
736 s.profile.writable.push(canonical(dir));
737 Self::Sandboxed(s)
738 }
739 Self::Plain => Self::Plain,
740 }
741 }
742
743 #[must_use]
746 pub fn allows_network(&self, argv: &[String]) -> bool {
747 matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
748 }
749
750 #[must_use]
752 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
753 match self {
754 Self::Plain => LocalRunner.run(inv),
755 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
756 Self::Sandboxed(s) => s.run(inv),
757 }
758 }
759
760 #[must_use]
763 pub fn scratch(&self) -> Option<PathBuf> {
764 match self {
765 Self::Plain => None,
766 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
767 .ok()
768 .map(|()| s.profile.tmp.clone()),
769 }
770 }
771
772 pub fn wrap(&self, argv: &[String]) -> Result<Wrapped, String> {
776 match self {
777 Self::Plain => Ok((argv.to_vec(), None)),
778 Self::Sandboxed(s) => {
779 let profile = s.profile.clone().with_network(s.network_for(argv));
780 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
781 Ok((
782 wrap(&profile, &s.backend, argv)?,
783 Some(profile.private_env()),
784 ))
785 }
786 }
787 }
788
789 #[must_use]
792 pub fn fetching(self) -> Self {
793 match self {
794 Self::Sandboxed(mut s) => {
795 s.profile = s.profile.fetching();
796 Self::Sandboxed(s)
797 }
798 Self::Plain => Self::Plain,
799 }
800 }
801}
802
803impl CommandRunner for Runner {
804 fn run(&self, inv: &Invocation) -> Outcome {
805 self.run_with(inv, false)
806 }
807}
808
809pub fn helper_main(args: &[String]) -> String {
812 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
813 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
814 };
815 let profile: Profile = match serde_json::from_str(profile) {
816 Ok(p) => p,
817 Err(e) => return format!("the sandbox profile: {e}"),
818 };
819 let argv = &args[2..];
820 if argv.is_empty() {
821 return "nothing to run".to_owned();
822 }
823 #[cfg(any(target_os = "macos", target_os = "linux"))]
824 return procage::exec(&profile, argv);
825 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
826 format!("no sandbox for {}", std::env::consts::OS)
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn config_parses_and_defaults_to_loopback() {
835 assert_eq!(parse("").unwrap_or_default(), Config::default());
836 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
837 .unwrap_or_default();
838 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
839 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
840 }
841
842 #[test]
843 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
844 let home = Path::new("/home/u");
845 let p = Profile::new(
846 Path::new("/s/proj/worktrees/001"),
847 &Config::default(),
848 Some(home),
849 );
850 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
851 assert!(
852 !p.writable
853 .iter()
854 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
855 );
856 assert!(p.hidden.contains(&home.join(".ssh")));
857 assert_eq!(p.unenforced, Unenforced::default());
858 }
859
860 #[test]
863 fn caches_are_written_only_by_what_fetches() {
864 let home = Path::new("/home/u");
865 let registry = home.join(".cargo/registry");
866 let p = Profile::new(Path::new("/w"), &Config::default(), Some(home));
867 assert!(p.caches.contains(®istry));
868 assert!(!p.writable.contains(®istry));
869 let env = p.private_env();
870 let var = |k: &str| env.iter().find(|(e, _)| e == k).map(|(_, v)| v.clone());
871 let cache = p.tmp.join("cache");
872 assert_eq!(
873 var("UV_CACHE_DIR"),
874 Some(cache.join("uv").to_string_lossy().into_owned())
875 );
876 assert_eq!(
877 var("GOCACHE"),
878 Some(cache.join("go-build").to_string_lossy().into_owned())
879 );
880 assert_eq!(
881 var("XDG_CACHE_HOME"),
882 Some(cache.to_string_lossy().into_owned())
883 );
884 for f in [p.clone().fetching(), p.clone().with_network(Network::On)] {
885 assert!(f.writable.contains(®istry));
886 assert!(f.private_env().iter().all(|(k, _)| k != "UV_CACHE_DIR"));
887 }
888 assert!(
889 !p.with_network(Network::Loopback)
890 .writable
891 .contains(®istry)
892 );
893 }
894
895 #[test]
898 fn the_signal_scope_is_enforced_unless_the_profile_says_otherwise() {
899 let p = Profile::new(Path::new("/w"), &Config::default(), None);
900 let mut json: serde_json::Value = serde_json::to_value(&p).unwrap();
901 json["unenforced"] =
902 serde_json::json!({"trust_handoff": false, "workspace_sockets": false});
903 let old: Profile = serde_json::from_value(json).unwrap();
904 assert!(!old.unenforced.signal_scope);
905 let mut fitted = p.clone();
906 fitted.unenforced.signal_scope = true;
907 let back: Profile = serde_json::from_str(&serde_json::to_string(&fitted).unwrap()).unwrap();
908 assert!(back.unenforced.signal_scope);
909 }
910
911 #[test]
912 fn the_executable_is_the_first_on_the_path_canonicalised() {
913 use std::os::unix::fs::PermissionsExt;
914 let dir = tempfile::tempdir().unwrap();
915 let root = std::fs::canonicalize(dir.path()).unwrap();
916 for d in ["bin", "usr"] {
917 std::fs::create_dir_all(root.join(d)).unwrap();
918 let f = root.join(d).join("make");
919 std::fs::write(&f, "#!/bin/sh\n").unwrap();
920 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
921 }
922 let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
923 let usr = format!("{}", root.join("usr").display());
924 assert_eq!(
925 resolve_executable("make", &root, &env(&usr)),
926 Some(root.join("usr/make"))
927 );
928 assert_eq!(
930 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
931 Some(root.join("bin/make"))
932 );
933 assert_eq!(
934 resolve_executable("./bin/make", &root, &env("")),
935 Some(root.join("bin/make"))
936 );
937 assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
938 assert!(!resolvable("nope", &root, &env(&usr)));
939 std::fs::set_permissions(
941 root.join("bin/make"),
942 std::fs::Permissions::from_mode(0o644),
943 )
944 .unwrap();
945 assert_eq!(
946 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
947 Some(root.join("usr/make"))
948 );
949 assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
950 }
951
952 #[test]
954 fn the_engine_key_is_refused_by_name() {
955 let e = parse("[sandbox]\nengine = \"procage\"\n").unwrap_err();
956 assert!(e.contains("engine"), "{e}");
957 }
958
959 #[cfg(target_os = "macos")]
963 #[test]
964 fn an_unconfinable_profile_follows_the_mode() {
965 if backend().is_err() {
966 return; }
968 let dir = tempfile::tempdir().unwrap();
969 let odd = dir.path().join("procage\u{1}odd");
970 std::fs::create_dir_all(&odd).unwrap();
971 let mut config = Config {
972 writable: vec![odd.to_string_lossy().into_owned()],
973 ..Config::default()
974 };
975 let (plain, notice) = runner(dir.path(), &config).unwrap();
976 assert!(!plain.is_sandboxed());
977 assert!(
978 notice.is_some_and(|n| n.starts_with("commands run unsandboxed: ")),
979 "{plain:?}"
980 );
981 config.mode = Mode::Required;
982 let e = runner(dir.path(), &config).unwrap_err();
983 assert!(e.contains("mode = \"required\""), "{e}");
984 }
985
986 #[test]
987 fn a_confinable_profile_is_sandboxed() {
988 let dir = tempfile::tempdir().unwrap();
989 let Ok((Runner::Sandboxed(s), _)) = runner(dir.path(), &Config::default()) else {
990 assert!(
991 backend().is_err(),
992 "a sandbox exists but the runner is plain"
993 );
994 return;
995 };
996 assert_eq!(s.backend, backend().unwrap());
997 }
998
999 #[test]
1000 fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
1001 let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
1002 let s = Sandboxed {
1003 profile: Profile::new(Path::new("/w"), &c, None),
1004 backend: Backend { helper: None },
1005 network_allow: c.network_allow.clone(),
1006 network_allow_prefix: c.network_allow_prefix.clone(),
1007 };
1008 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1009 assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
1010 assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
1011 assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
1012 assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
1013 }
1014}