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)]
204 pub unenforced: Unenforced,
205}
206
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields, default)]
213pub struct Unenforced {
214 pub trust_handoff: bool,
216 pub workspace_sockets: bool,
218 pub signal_scope: bool,
221 pub carve: bool,
224}
225
226fn canonical(p: &Path) -> PathBuf {
227 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
228}
229
230impl Profile {
231 #[must_use]
233 pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
234 let root = canonical(root);
235 let expand = |p: &str| -> Option<PathBuf> {
236 match (p.strip_prefix("~/"), home) {
237 (Some(rest), Some(h)) => Some(h.join(rest)),
238 (Some(_), None) => None,
239 (None, _) => Some(PathBuf::from(p)),
240 }
241 };
242 let home = home.map(canonical);
243 let home = home.as_deref();
244 let mut writable = vec![root.clone()];
245 writable.extend(
246 home.into_iter()
247 .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
248 );
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 Self {
271 writable,
272 hidden,
273 read_only: Vec::new(),
274 network: config.network,
275 tmp,
276 unenforced: Unenforced::default(),
277 }
278 }
279
280 #[must_use]
283 pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
284 self.read_only
285 .extend(read_only.iter().map(|p| canonical(p)));
286 self.hidden.extend(hidden.iter().map(|p| canonical(p)));
287 self
288 }
289
290 #[must_use]
292 pub fn with_network(mut self, network: Network) -> Self {
293 self.network = network;
294 self
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct Backend {
304 pub helper: Option<PathBuf>,
306}
307
308impl Backend {
309 #[must_use]
312 pub fn mechanism(&self) -> &'static str {
313 if cfg!(target_os = "macos") {
314 "Seatbelt"
315 } else {
316 "Landlock+seccomp"
317 }
318 }
319}
320
321pub const HELPER_ARG: &str = "__sandbox";
323
324pub fn backend() -> Result<Backend, String> {
326 static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
327 CACHED.get_or_init(detect).clone()
328}
329
330fn detect() -> Result<Backend, String> {
331 available()?;
332 Ok(Backend {
333 helper: helper_binary(),
334 })
335}
336
337pub fn available() -> Result<(), String> {
343 #[cfg(any(target_os = "macos", target_os = "linux"))]
344 return procage::available();
345 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
346 Err(format!("no sandbox for {}", std::env::consts::OS))
347}
348
349fn helper_binary() -> Option<PathBuf> {
352 std::env::var_os("SPECCODER_SANDBOX_HELPER")
353 .map(PathBuf::from)
354 .or_else(|| {
355 std::env::current_exe()
356 .ok()
357 .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
358 })
359}
360
361pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
368 #[cfg(any(target_os = "macos", target_os = "linux"))]
369 return procage::fit(profile);
370 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
371 {
372 let _ = profile;
373 Err(format!("no sandbox for {}", std::env::consts::OS))
374 }
375}
376
377pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
379 let helper = backend.helper.as_ref().ok_or_else(|| {
381 "servers start through the speccoder binary, which was not found (SPECCODER_SANDBOX_HELPER)"
382 .to_owned()
383 })?;
384 let mut out = vec![
385 helper.to_string_lossy().into_owned(),
386 HELPER_ARG.to_owned(),
387 serde_json::to_string(p).map_err(|e| e.to_string())?,
388 "--".to_owned(),
389 ];
390 out.extend_from_slice(argv);
391 Ok(out)
392}
393
394#[derive(Debug, Clone)]
396pub struct Sandboxed {
397 pub profile: Profile,
399 pub backend: Backend,
401 pub network_allow: Vec<Vec<String>>,
403 pub network_allow_prefix: Vec<Vec<String>>,
405}
406
407impl Sandboxed {
408 #[must_use]
411 pub fn network_for(&self, argv: &[String]) -> Network {
412 if self.network_allow.iter().any(|p| p.as_slice() == argv)
413 || self
414 .network_allow_prefix
415 .iter()
416 .any(|p| crate::tools::policy::prefix_matches(p, argv))
417 {
418 Network::On
419 } else {
420 self.profile.network
421 }
422 }
423
424 #[must_use]
426 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
427 let profile = self.profile.clone().with_network(network);
428 let failed = |e: String| Outcome {
429 exit_code: None,
430 timed_out: false,
431 spawn_error: Some(e),
432 started: std::time::SystemTime::now(),
433 duration: std::time::Duration::ZERO,
434 stdout: crate::verify::process::Captured::default(),
435 stderr: crate::verify::process::Captured::default(),
436 sandbox: Default::default(),
437 };
438 if let Some(program) = inv.argv.first()
441 && !resolvable(program, &inv.cwd, &inv.env)
442 {
443 return failed(format!(
444 "could not start `{program}`: No such file or directory"
445 ));
446 }
447 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
448 return failed(format!("the sandbox's temporary directory: {e}"));
449 }
450 let tmp = profile.tmp.to_string_lossy().into_owned();
451 let mut env: Vec<(String, String)> = inv
452 .env
453 .iter()
454 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
455 .cloned()
456 .collect();
457 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
458 #[cfg(any(target_os = "macos", target_os = "linux"))]
459 return procage::run(&profile, &Invocation { env, ..inv.clone() });
460 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
461 {
462 let _ = env;
463 failed(format!("no sandbox for {}", std::env::consts::OS))
464 }
465 }
466}
467
468#[must_use]
475pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
476 let candidate = |p: PathBuf| {
477 executable_file(&p)
478 .then(|| std::fs::canonicalize(&p).ok())
479 .flatten()
480 };
481 if program.is_empty() {
482 return None;
483 }
484 if program.contains('/') {
485 return candidate(cwd.join(program));
486 }
487 let path = env
488 .iter()
489 .find(|(k, _)| k == "PATH")
490 .map(|(_, v)| v.as_str());
491 path.unwrap_or("/usr/bin:/bin")
492 .split(':')
493 .filter(|d| !d.is_empty())
494 .find_map(|d| candidate(cwd.join(d).join(program)))
495}
496
497fn executable_file(p: &Path) -> bool {
499 #[cfg(unix)]
500 {
501 use std::os::unix::fs::PermissionsExt as _;
502 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
503 }
504 #[cfg(not(unix))]
505 {
506 p.is_file()
507 }
508}
509
510fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
512 resolve_executable(program, cwd, env).is_some()
513}
514
515impl CommandRunner for Sandboxed {
516 fn run(&self, inv: &Invocation) -> Outcome {
517 self.run_with_network(inv, self.network_for(&inv.argv))
518 }
519}
520
521pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
525 if config.mode == Mode::Off {
526 return Ok((
527 Runner::Plain,
528 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
529 ));
530 }
531 let home = std::env::var_os("HOME").map(PathBuf::from);
532 let profile = Profile::new(root, config, home.as_deref());
533 match backend().and_then(|backend| Ok((backend, fit(&profile)?))) {
536 Ok((backend, (profile, notice))) => Ok((
537 Runner::Sandboxed(Sandboxed {
538 profile,
539 backend,
540 network_allow: config.network_allow.clone(),
541 network_allow_prefix: config.network_allow_prefix.clone(),
542 }),
543 notice,
544 )),
545 Err(e) if config.mode == Mode::Required => Err(format!(
546 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
547 )),
548 Err(e) => Ok((
549 Runner::Plain,
550 Some(format!("commands run unsandboxed: {e}")),
551 )),
552 }
553}
554
555#[must_use]
559pub fn contained_at(root: &Path) -> (bool, String) {
560 let text = crate::verify::find_config(root)
561 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
562 .unwrap_or_default();
563 let allowed = crate::substrate::run_config::parse(&text).is_ok_and(|c| c.contained);
564 if !allowed {
565 return (
566 true,
567 "every command that is not a check asks ([approvals] contained = \"ask\")".to_owned(),
568 );
569 }
570 match runner_at(root).map(|(r, _)| r.confines()) {
571 Ok(Some(m)) => (
572 true,
573 format!(
574 "commands the sandbox confines run without asking ({m}) — [approvals] contained = \"ask\" to be asked"
575 ),
576 ),
577 Ok(None) | Err(_) => (
578 false,
579 "every command that is not a check asks: no sandbox confines them here".to_owned(),
580 ),
581 }
582}
583
584pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
586 let text = crate::verify::find_config(root)
587 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
588 .unwrap_or_default();
589 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
590 runner(root, &config)
591}
592
593impl Runner {
594 #[must_use]
598 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
599 match self {
600 Self::Sandboxed(mut s) => {
601 let carved = s.profile.clone().carve(read_only, hidden);
602 match fit(&carved) {
603 Ok((fitted, notice)) => {
604 s.profile = fitted;
605 (Self::Sandboxed(s), notice)
606 }
607 Err(e) => (
608 Self::Sandboxed({
609 s.profile.unenforced.carve = true;
610 s
611 }),
612 Some(format!(
613 "the sandbox cannot carve here ({e}): commands can write `.git` and read ignored secrets in place; the run stops if `.git` changes"
614 )),
615 ),
616 }
617 }
618 Self::Plain => (Self::Plain, None),
619 }
620 }
621}
622
623#[derive(Debug, Clone)]
625pub enum Runner {
626 Sandboxed(Sandboxed),
628 Plain,
630}
631
632impl Runner {
633 #[must_use]
635 pub fn is_sandboxed(&self) -> bool {
636 matches!(self, Self::Sandboxed(_))
637 }
638
639 #[must_use]
644 pub fn confines(&self) -> Option<&'static str> {
645 match self {
646 Self::Sandboxed(s)
647 if s.profile.network != Network::On
648 && !s.profile.unenforced.signal_scope
649 && !s.profile.unenforced.carve =>
650 {
651 Some(s.backend.mechanism())
652 }
653 _ => None,
654 }
655 }
656
657 #[must_use]
662 pub fn with_trust_handoff_writable(self) -> Self {
663 match self {
664 Self::Sandboxed(mut s) => {
665 s.profile.unenforced.trust_handoff = true;
666 Self::Sandboxed(s)
667 }
668 Self::Plain => Self::Plain,
669 }
670 }
671
672 #[must_use]
674 pub fn with_writable(self, dir: &Path) -> Self {
675 match self {
676 Self::Sandboxed(mut s) => {
677 s.profile.writable.push(canonical(dir));
678 Self::Sandboxed(s)
679 }
680 Self::Plain => Self::Plain,
681 }
682 }
683
684 #[must_use]
687 pub fn allows_network(&self, argv: &[String]) -> bool {
688 matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
689 }
690
691 #[must_use]
693 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
694 match self {
695 Self::Plain => LocalRunner.run(inv),
696 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
697 Self::Sandboxed(s) => s.run(inv),
698 }
699 }
700
701 #[must_use]
704 pub fn scratch(&self) -> Option<PathBuf> {
705 match self {
706 Self::Plain => None,
707 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
708 .ok()
709 .map(|()| s.profile.tmp.clone()),
710 }
711 }
712
713 pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
715 match self {
716 Self::Plain => Ok((argv.to_vec(), None)),
717 Self::Sandboxed(s) => {
718 let profile = s.profile.clone().with_network(s.network_for(argv));
719 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
720 Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
721 }
722 }
723 }
724}
725
726impl CommandRunner for Runner {
727 fn run(&self, inv: &Invocation) -> Outcome {
728 self.run_with(inv, false)
729 }
730}
731
732pub fn helper_main(args: &[String]) -> String {
735 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
736 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
737 };
738 let profile: Profile = match serde_json::from_str(profile) {
739 Ok(p) => p,
740 Err(e) => return format!("the sandbox profile: {e}"),
741 };
742 let argv = &args[2..];
743 if argv.is_empty() {
744 return "nothing to run".to_owned();
745 }
746 #[cfg(any(target_os = "macos", target_os = "linux"))]
747 return procage::exec(&profile, argv);
748 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
749 format!("no sandbox for {}", std::env::consts::OS)
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
757 fn config_parses_and_defaults_to_loopback() {
758 assert_eq!(parse("").unwrap_or_default(), Config::default());
759 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
760 .unwrap_or_default();
761 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
762 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
763 }
764
765 #[test]
766 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
767 let home = Path::new("/home/u");
768 let p = Profile::new(
769 Path::new("/s/proj/worktrees/001"),
770 &Config::default(),
771 Some(home),
772 );
773 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
774 assert!(
775 !p.writable
776 .iter()
777 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
778 );
779 assert!(p.hidden.contains(&home.join(".ssh")));
780 assert!(p.writable.contains(&home.join(".cargo/registry")));
781 assert_eq!(p.unenforced, Unenforced::default());
782 }
783
784 #[test]
787 fn the_signal_scope_is_enforced_unless_the_profile_says_otherwise() {
788 let p = Profile::new(Path::new("/w"), &Config::default(), None);
789 let mut json: serde_json::Value = serde_json::to_value(&p).unwrap();
790 json["unenforced"] =
791 serde_json::json!({"trust_handoff": false, "workspace_sockets": false});
792 let old: Profile = serde_json::from_value(json).unwrap();
793 assert!(!old.unenforced.signal_scope);
794 let mut fitted = p.clone();
795 fitted.unenforced.signal_scope = true;
796 let back: Profile = serde_json::from_str(&serde_json::to_string(&fitted).unwrap()).unwrap();
797 assert!(back.unenforced.signal_scope);
798 }
799
800 #[test]
801 fn the_executable_is_the_first_on_the_path_canonicalised() {
802 use std::os::unix::fs::PermissionsExt;
803 let dir = tempfile::tempdir().unwrap();
804 let root = std::fs::canonicalize(dir.path()).unwrap();
805 for d in ["bin", "usr"] {
806 std::fs::create_dir_all(root.join(d)).unwrap();
807 let f = root.join(d).join("make");
808 std::fs::write(&f, "#!/bin/sh\n").unwrap();
809 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
810 }
811 let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
812 let usr = format!("{}", root.join("usr").display());
813 assert_eq!(
814 resolve_executable("make", &root, &env(&usr)),
815 Some(root.join("usr/make"))
816 );
817 assert_eq!(
819 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
820 Some(root.join("bin/make"))
821 );
822 assert_eq!(
823 resolve_executable("./bin/make", &root, &env("")),
824 Some(root.join("bin/make"))
825 );
826 assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
827 assert!(!resolvable("nope", &root, &env(&usr)));
828 std::fs::set_permissions(
830 root.join("bin/make"),
831 std::fs::Permissions::from_mode(0o644),
832 )
833 .unwrap();
834 assert_eq!(
835 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
836 Some(root.join("usr/make"))
837 );
838 assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
839 }
840
841 #[test]
843 fn the_engine_key_is_refused_by_name() {
844 let e = parse("[sandbox]\nengine = \"procage\"\n").unwrap_err();
845 assert!(e.contains("engine"), "{e}");
846 }
847
848 #[cfg(target_os = "macos")]
852 #[test]
853 fn an_unconfinable_profile_follows_the_mode() {
854 if backend().is_err() {
855 return; }
857 let dir = tempfile::tempdir().unwrap();
858 let odd = dir.path().join("procage\u{1}odd");
859 std::fs::create_dir_all(&odd).unwrap();
860 let mut config = Config {
861 writable: vec![odd.to_string_lossy().into_owned()],
862 ..Config::default()
863 };
864 let (plain, notice) = runner(dir.path(), &config).unwrap();
865 assert!(!plain.is_sandboxed());
866 assert!(
867 notice.is_some_and(|n| n.starts_with("commands run unsandboxed: ")),
868 "{plain:?}"
869 );
870 config.mode = Mode::Required;
871 let e = runner(dir.path(), &config).unwrap_err();
872 assert!(e.contains("mode = \"required\""), "{e}");
873 }
874
875 #[test]
876 fn a_confinable_profile_is_sandboxed() {
877 let dir = tempfile::tempdir().unwrap();
878 let Ok((Runner::Sandboxed(s), _)) = runner(dir.path(), &Config::default()) else {
879 assert!(
880 backend().is_err(),
881 "a sandbox exists but the runner is plain"
882 );
883 return;
884 };
885 assert_eq!(s.backend, backend().unwrap());
886 }
887
888 #[test]
889 fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
890 let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
891 let s = Sandboxed {
892 profile: Profile::new(Path::new("/w"), &c, None),
893 backend: Backend { helper: None },
894 network_allow: c.network_allow.clone(),
895 network_allow_prefix: c.network_allow_prefix.clone(),
896 };
897 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
898 assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
899 assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
900 assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
901 assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
902 }
903}