1use std::ffi::OsString;
41use std::path::PathBuf;
42#[cfg(target_os = "linux")]
43use std::sync::OnceLock;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum ScopeKind {
48 Tool,
50 Daemon,
52}
53
54impl ScopeKind {
55 pub fn slice_key(self) -> &'static str {
57 match self {
58 ScopeKind::Tool => "tool_scope_slice",
59 ScopeKind::Daemon => "daemon_scope_slice",
60 }
61 }
62}
63
64fn slice_for(kind: ScopeKind, config: &crate::config::SynapsConfig) -> Option<String> {
67 match kind {
68 ScopeKind::Tool => config.tool_scope_slice.clone(),
69 ScopeKind::Daemon => config.daemon_scope_slice.clone(),
70 }
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct ScopeLauncher {
76 systemd_run: PathBuf,
77 no_expand: bool,
80 slice: Option<String>,
82}
83
84#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const EXPAND_ENVIRONMENT_SINCE: u32 = 254;
94
95fn scope_options(description: &str, no_expand: bool, slice: Option<&str>) -> Vec<OsString> {
98 let mut argv: Vec<OsString> = vec![
99 "--user".into(),
100 "--scope".into(),
101 "--quiet".into(),
102 "--collect".into(),
105 ];
106 if no_expand {
107 argv.push("--expand-environment=no".into());
111 }
112 if let Some(slice) = slice {
113 argv.push(format!("--slice={slice}").into());
114 }
115 argv.push(format!("--description={description}").into());
116 argv
117}
118
119impl ScopeLauncher {
120 pub fn wrap(
123 &self,
124 description: &str,
125 program: impl Into<OsString>,
126 args: impl IntoIterator<Item = OsString>,
127 ) -> (OsString, Vec<OsString>) {
128 let mut argv = scope_options(description, self.no_expand, self.slice.as_deref());
129 argv.push("--".into());
130 argv.push(program.into());
131 argv.extend(args);
132 (self.systemd_run.clone().into_os_string(), argv)
133 }
134}
135
136#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn parse_systemd_version(output: &str) -> Option<u32> {
139 let rest = output.lines().next()?.trim().strip_prefix("systemd ")?;
140 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
141 digits.parse().ok()
142}
143
144#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn no_expand_for_version(version: Option<u32>) -> Option<bool> {
149 version.map(|v| v >= EXPAND_ENVIRONMENT_SINCE)
150}
151
152fn enabled_from(env_value: Option<&str>, config_value: bool) -> bool {
155 match env_value.map(|v| v.trim().to_ascii_lowercase()) {
156 Some(v) if matches!(v.as_str(), "0" | "off" | "false" | "no") => false,
157 Some(v) if matches!(v.as_str(), "1" | "on" | "true" | "yes" | "auto") => true,
158 _ => config_value,
159 }
160}
161
162pub fn inherited_env() -> Vec<(OsString, OsString)> {
164 std::env::vars_os().collect()
165}
166
167pub fn set_env(env: &mut Vec<(OsString, OsString)>, key: &str, value: impl Into<OsString>) {
168 env.retain(|(k, _)| k != key);
169 env.push((key.into(), value.into()));
170}
171
172#[cfg(target_os = "linux")]
173fn find_systemd_run() -> Option<PathBuf> {
174 ["/usr/bin/systemd-run", "/bin/systemd-run"]
177 .into_iter()
178 .map(PathBuf::from)
179 .find(|p| p.is_file())
180}
181
182#[cfg(target_os = "linux")]
185fn has_manager_env(env: &[(OsString, OsString)]) -> bool {
186 env.iter()
187 .any(|(k, v)| (k == "XDG_RUNTIME_DIR" || k == "DBUS_SESSION_BUS_ADDRESS") && !v.is_empty())
188}
189
190#[cfg(target_os = "linux")]
194fn probe(
195 systemd_run: &std::path::Path,
196 no_expand: bool,
197 env: &[(OsString, OsString)],
198 cwd: Option<&std::path::Path>,
199 slice: Option<&str>,
200) -> bool {
201 if !has_manager_env(env) {
202 return false;
203 }
204 let mut cmd = std::process::Command::new(systemd_run);
205 cmd.env_clear().envs(env.iter().cloned());
206 if let Some(cwd) = cwd {
207 cmd.current_dir(cwd);
208 }
209 cmd.args(scope_options("synaps scope probe", no_expand, slice))
210 .args(["--", "/bin/true"])
211 .stdout(std::process::Stdio::null());
212 run_bounded(cmd).is_some_and(|(status, _)| status.success())
213}
214
215#[cfg(target_os = "linux")]
217fn systemd_run_version(systemd_run: &std::path::Path) -> Option<u32> {
218 let mut cmd = std::process::Command::new(systemd_run);
219 cmd.env_clear()
220 .env("LC_ALL", "C")
221 .env("SYSTEMD_PAGER", "")
222 .arg("--version")
223 .stdout(std::process::Stdio::piped());
224 let (status, stdout) = run_bounded(cmd)?;
225 status.success().then_some(())?;
226 parse_systemd_version(&String::from_utf8_lossy(&stdout))
227}
228
229#[cfg(target_os = "linux")]
233fn run_bounded(mut cmd: std::process::Command) -> Option<(std::process::ExitStatus, Vec<u8>)> {
234 use std::io::Read;
235 use std::os::unix::process::CommandExt;
236 use std::time::{Duration, Instant};
237 unsafe {
239 cmd.pre_exec(|| {
240 if libc::setsid() < 0 {
241 return Err(std::io::Error::last_os_error());
242 }
243 Ok(())
244 });
245 }
246 let mut child = cmd
247 .stdin(std::process::Stdio::null())
248 .stderr(std::process::Stdio::null())
249 .spawn()
250 .ok()?;
251 let deadline = Instant::now() + Duration::from_secs(3);
252 loop {
253 match child.try_wait() {
254 Ok(Some(status)) => {
255 let mut out = Vec::new();
257 if let Some(mut stdout) = child.stdout.take() {
258 let _ = stdout.by_ref().take(64 * 1024).read_to_end(&mut out);
259 }
260 return Some((status, out));
261 }
262 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)),
263 _ => {
264 unsafe {
266 libc::killpg(child.id() as libc::pid_t, libc::SIGKILL);
267 }
268 let _ = child.kill();
269 let cleanup_deadline = Instant::now() + Duration::from_millis(100);
270 while matches!(child.try_wait(), Ok(None)) && Instant::now() < cleanup_deadline {
271 std::thread::sleep(Duration::from_millis(5));
272 }
273 std::thread::spawn(move || {
276 let _ = child.wait();
277 });
278 return None;
279 }
280 }
281 }
282}
283
284#[cfg(test)]
293fn test_fake_systemd_run(env: &[(OsString, OsString)]) -> Option<PathBuf> {
294 env.iter()
295 .find(|(k, _)| k == "SYNAPS_TEST_FAKE_SYSTEMD_RUN")
296 .map(|(_, v)| PathBuf::from(v))
297}
298
299pub fn launcher_for_effective_env(
307 env: &[(OsString, OsString)],
308 cwd: Option<&std::path::Path>,
309 kind: ScopeKind,
310) -> Option<ScopeLauncher> {
311 #[cfg(test)]
314 let fake = test_fake_systemd_run(env);
315 #[cfg(not(test))]
316 let fake: Option<PathBuf> = None;
317 #[cfg(any(test, feature = "testing"))]
321 if fake.is_none() && std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") {
322 return None;
323 }
324 let config = crate::config::load_config();
327 if !enabled_from(
328 std::env::var("SYNAPS_PROCESS_SCOPES").ok().as_deref(),
329 config.process_scopes,
330 ) {
331 return None;
332 }
333 let slice = slice_for(kind, &config);
334 #[cfg(target_os = "linux")]
335 {
336 if let Some(fake) = fake {
337 return launcher_with_binary(&fake, false, env, cwd, kind, slice);
340 }
341 static BINARY: OnceLock<Option<PathBuf>> = OnceLock::new();
342 let binary = BINARY.get_or_init(find_systemd_run).as_ref()?;
343 static NO_EXPAND: OnceLock<bool> = OnceLock::new();
346 let no_expand = match NO_EXPAND.get() {
347 Some(v) => *v,
348 None => {
349 let v = no_expand_for_version(systemd_run_version(binary))?;
350 *NO_EXPAND.get_or_init(|| v)
351 }
352 };
353 launcher_with_binary(binary, no_expand, env, cwd, kind, slice)
354 }
355 #[cfg(not(target_os = "linux"))]
356 {
357 let _ = (env, cwd, fake, slice);
358 None
359 }
360}
361
362#[cfg(target_os = "linux")]
363fn launcher_with_binary(
364 binary: &std::path::Path,
365 no_expand: bool,
366 env: &[(OsString, OsString)],
367 cwd: Option<&std::path::Path>,
368 kind: ScopeKind,
369 slice: Option<String>,
370) -> Option<ScopeLauncher> {
371 if probe(binary, no_expand, env, cwd, slice.as_deref()) {
372 if let Some(slice) = slice.as_deref() {
373 if first_limits_check(slice) {
374 spawn_slice_limits_check(binary, env, kind, slice);
375 }
376 }
377 return Some(ScopeLauncher {
378 systemd_run: binary.to_owned(),
379 no_expand,
380 slice,
381 });
382 }
383 if let Some(slice) = slice.filter(|_| has_manager_env(env)) {
385 if first_refusal(&slice) {
386 tracing::warn!(key = kind.slice_key(), slice = %slice, "{}", probe_failed_warning(&slice));
387 }
388 }
389 None
390}
391
392#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn probe_failed_warning(slice: &str) -> String {
397 format!(
398 "process scope: scope probe failed (user manager unreachable, or slice {slice} refused); these launches start without a scope"
399 )
400}
401
402#[cfg(target_os = "linux")]
404fn first_in(seen: &std::sync::Mutex<Vec<String>>, name: &str) -> bool {
405 let mut seen = seen.lock().unwrap_or_else(|e| e.into_inner());
406 if seen.iter().any(|s| s == name) {
407 return false;
408 }
409 seen.push(name.to_owned());
410 true
411}
412
413#[cfg(target_os = "linux")]
416fn first_refusal(slice: &str) -> bool {
417 static WARNED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
418 first_in(&WARNED, slice)
419}
420
421#[cfg(target_os = "linux")]
424fn first_limits_check(slice: &str) -> bool {
425 static CHECKED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
426 first_in(&CHECKED, slice)
427}
428
429pub fn launcher_for_env(env: Option<&[(String, String)]>) -> Option<ScopeLauncher> {
432 let env = env
433 .map(|e| e.iter().map(|(k, v)| (k.into(), v.into())).collect())
434 .unwrap_or_else(inherited_env);
435 launcher_for_effective_env(&env, None, ScopeKind::Tool)
436}
437
438const SLICE_LIMIT_PROPERTIES: &str = "MemoryHigh,MemoryMax,MemorySwapMax,CPUWeight,CPUQuotaPerSecUSec,TasksMax";
442
443#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn slice_without_limits(show: &str) -> Option<bool> {
451 let mut limits_seen = 0;
452 let mut unit_file = false;
453 for line in show.lines() {
454 let Some((key, value)) = line.split_once('=') else {
455 continue;
456 };
457 if key == "FragmentPath" {
458 unit_file = !value.is_empty();
459 } else if SLICE_LIMIT_PROPERTIES.split(',').any(|p| p == key) {
460 if !matches!(value, "" | "infinity" | "[not set]" | "18446744073709551615") {
461 return None;
462 }
463 limits_seen += 1;
464 }
465 }
466 (limits_seen == SLICE_LIMIT_PROPERTIES.split(',').count()).then_some(unit_file)
467}
468
469#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn no_limits_warning(slice: &str, unit_file: bool) -> String {
472 let why = if unit_file {
473 "its unit sets none"
474 } else {
475 "no unit file defines it: a typo, or a new unit not yet loaded with `systemctl --user daemon-reload`?"
476 };
477 format!(
478 "process scope: slice {slice} has no MemoryHigh, MemoryMax, MemorySwapMax, CPUWeight, CPUQuota or TasksMax set ({why}); scopes in it run uncapped"
479 )
480}
481
482#[cfg(target_os = "linux")]
487fn query_slice_without_limits(
488 systemctl: &std::path::Path,
489 env: &[(OsString, OsString)],
490 slice: &str,
491) -> Option<bool> {
492 let mut cmd = std::process::Command::new(systemctl);
493 cmd.env_clear()
494 .envs(env.iter().cloned())
495 .env("LC_ALL", "C")
496 .env("SYSTEMD_PAGER", "")
497 .args(["--user", "show", "-p", SLICE_LIMIT_PROPERTIES, "-p", "FragmentPath", "--", slice])
498 .stdout(std::process::Stdio::piped());
499 let (status, stdout) = run_bounded(cmd)?;
500 status.success().then_some(())?;
501 slice_without_limits(&String::from_utf8_lossy(&stdout))
502}
503
504#[cfg(target_os = "linux")]
508fn spawn_slice_limits_check(systemd_run: &std::path::Path, env: &[(OsString, OsString)], kind: ScopeKind, slice: &str) {
509 let systemctl = systemd_run.with_file_name("systemctl");
510 let env = env.to_vec();
511 let slice = slice.to_owned();
512 let _ = std::thread::Builder::new()
513 .name("synaps-slice-limits".into())
514 .spawn(move || {
515 if let Some(unit_file) = query_slice_without_limits(&systemctl, &env, &slice) {
516 tracing::warn!(key = kind.slice_key(), slice = %slice, "{}", no_limits_warning(&slice, unit_file));
517 }
518 });
519}
520
521pub fn resolve_program(
524 program: OsString,
525 env: &[(OsString, OsString)],
526 cwd: Option<&std::path::Path>,
527) -> std::io::Result<OsString> {
528 #[cfg(unix)]
529 {
530 use std::os::unix::fs::PermissionsExt;
531 let base = cwd
532 .map(PathBuf::from)
533 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
534 let base = if base.is_absolute() {
535 base
536 } else {
537 std::env::current_dir().unwrap_or_default().join(base)
538 };
539 if std::path::Path::new(&program).components().count() > 1 {
540 return Ok(base.join(program).into_os_string());
541 }
542 let path = env
543 .iter()
544 .find(|(k, _)| k == "PATH")
545 .map(|(_, v)| v.clone())
546 .unwrap_or_else(|| "/bin:/usr/bin".into());
547 for dir in std::env::split_paths(&path) {
548 let candidate = base.join(dir).join(&program);
549 if candidate
550 .metadata()
551 .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
552 {
553 return Ok(candidate.into_os_string());
554 }
555 }
556 }
557 #[cfg(not(unix))]
558 let _ = (env, cwd);
559 Err(std::io::Error::new(
560 std::io::ErrorKind::NotFound,
561 format!("program not found in child PATH: {program:?}"),
562 ))
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn wrapped(no_expand: bool, script: &str) -> (OsString, Vec<String>) {
570 let l = ScopeLauncher {
571 systemd_run: PathBuf::from("/usr/bin/systemd-run"),
572 no_expand,
573 slice: None,
574 };
575 let (program, args) = l.wrap("synaps tool: bash", "bash", ["-c".into(), script.into()]);
576 let args = args
577 .iter()
578 .map(|a| a.to_string_lossy().into_owned())
579 .collect();
580 (program, args)
581 }
582
583 #[test]
584 fn wrap_disables_expansion_when_systemd_run_supports_it() {
585 let (program, args) = wrapped(true, "echo 'D:${x}' $HOME");
586 assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
587 assert_eq!(
588 args,
589 [
590 "--user",
591 "--scope",
592 "--quiet",
593 "--collect",
594 "--expand-environment=no",
595 "--description=synaps tool: bash",
596 "--",
597 "bash",
598 "-c",
599 "echo 'D:${x}' $HOME",
600 ]
601 );
602 }
603
604 #[test]
605 fn wrap_never_passes_the_flag_to_a_systemd_run_without_it() {
606 let (program, args) = wrapped(false, "echo hi");
607 assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
608 assert_eq!(
609 args,
610 [
611 "--user",
612 "--scope",
613 "--quiet",
614 "--collect",
615 "--description=synaps tool: bash",
616 "--",
617 "bash",
618 "-c",
619 "echo hi",
620 ]
621 );
622 assert!(!args.iter().any(|a| a.starts_with("--expand-environment")));
623 }
624
625 #[test]
626 fn version_decides_the_flag_and_unknown_versions_decide_nothing() {
627 assert_eq!(
628 parse_systemd_version("systemd 259 (259.5-0ubuntu3.4)\n+PAM +AUDIT"),
629 Some(259)
630 );
631 assert_eq!(parse_systemd_version("systemd 254 (254.5-1)"), Some(254));
632 assert_eq!(parse_systemd_version("systemd 249\n"), Some(249));
633 assert_eq!(parse_systemd_version("systemd 219"), Some(219));
634 assert_eq!(parse_systemd_version(""), None);
635 assert_eq!(parse_systemd_version("something else 300"), None);
636 assert_eq!(parse_systemd_version("systemd x"), None);
637 assert_eq!(no_expand_for_version(Some(259)), Some(true));
638 assert_eq!(no_expand_for_version(Some(254)), Some(true));
639 assert_eq!(no_expand_for_version(Some(253)), Some(false));
640 assert_eq!(no_expand_for_version(Some(219)), Some(false));
641 assert_eq!(no_expand_for_version(None), None);
642 }
643
644 #[cfg(target_os = "linux")]
645 #[test]
646 fn version_query_and_probe_use_exactly_the_launch_options() {
647 let dir = tempfile::tempdir().unwrap();
648 let binary = fake_script(
650 dir.path(),
651 "systemd-run",
652 r#"
653[ "$1" = --version ] && { echo 'systemd 249 (249.11-0ubuntu3)'; exit 0; }
654for a in "$@"; do case "$a" in --expand-environment=*) exit 1;; --) break;; esac; done
655exit 0
656"#,
657 );
658 assert_eq!(systemd_run_version(&binary), Some(249));
659 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
660 assert!(launcher_with_binary(&binary, false, &env, None, ScopeKind::Tool, None).is_some());
661 assert!(launcher_with_binary(&binary, true, &env, None, ScopeKind::Tool, None).is_none());
664 fake_script(dir.path(), "systemd-run", "exit 1");
665 assert_eq!(systemd_run_version(&binary), None);
666 fake_script(dir.path(), "systemd-run", "echo 'systemd 258 (258)'");
667 assert_eq!(systemd_run_version(&binary), Some(258));
668 }
669
670 #[cfg(target_os = "linux")]
674 #[test]
675 fn real_scope_passes_dollar_braces_through_verbatim() {
676 if std::env::var("SYNAPS_PROCESS_SCOPES").is_ok_and(|v| !enabled_from(Some(&v), true)) {
677 eprintln!("skip: SYNAPS_PROCESS_SCOPES is off");
678 return;
679 }
680 let Some(binary) = find_systemd_run() else {
681 eprintln!("skip: no systemd-run");
682 return;
683 };
684 let Some(no_expand) = no_expand_for_version(systemd_run_version(&binary)) else {
685 eprintln!("skip: systemd-run --version unusable");
686 return;
687 };
688 let mut env = inherited_env();
689 set_env(&mut env, "x", "EXPANDED");
690 let Some(launcher) = launcher_with_binary(&binary, no_expand, &env, None, ScopeKind::Tool, None) else {
691 eprintln!("skip: no reachable systemd user manager");
692 return;
693 };
694 let payload = ["D:${x}", "$x", "'${x:-dflt}'", "`${a}${b}`", "$$", "${}"];
695 let mut args: Vec<OsString> =
696 vec!["-c".into(), "printf '%s\\n' \"$@\"".into(), "sh".into()];
697 args.extend(payload.iter().map(OsString::from));
698 let (program, argv) = launcher.wrap("synaps test: no expand", "/bin/sh", args);
699 let out = std::process::Command::new(program)
700 .args(argv)
701 .env_clear()
702 .envs(env)
703 .stdin(std::process::Stdio::null())
704 .output()
705 .unwrap();
706 assert!(
707 out.status.success(),
708 "{}",
709 String::from_utf8_lossy(&out.stderr)
710 );
711 let stdout = String::from_utf8(out.stdout).unwrap();
712 assert_eq!(stdout.lines().collect::<Vec<_>>(), payload);
713 assert!(
714 !String::from_utf8_lossy(&out.stderr).contains("environment variable"),
715 "{}",
716 String::from_utf8_lossy(&out.stderr)
717 );
718 }
719
720 #[test]
721 fn ordinary_test_build_does_not_probe_host_manager() {
722 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() == Ok("1") {
723 return;
724 }
725 let env = vec![(
726 "DBUS_SESSION_BUS_ADDRESS".into(),
727 "fake:must-not-probe".into(),
728 )];
729 assert!(launcher_for_effective_env(&env, None, ScopeKind::Tool).is_none());
730 }
731
732 #[test]
733 fn env_variable_overrides_config_and_unknown_values_fall_back_to_config() {
734 assert!(enabled_from(None, true));
735 assert!(!enabled_from(None, false));
736 for off in ["0", "off", "OFF", " false ", "no"] {
737 assert!(!enabled_from(Some(off), true), "{off:?}");
738 }
739 for on in ["1", "on", "true", "yes", "auto"] {
740 assert!(enabled_from(Some(on), false), "{on:?}");
741 }
742 assert!(enabled_from(Some("maybe"), true));
743 assert!(!enabled_from(Some("maybe"), false));
744 }
745
746 #[cfg(target_os = "linux")]
749 fn fake_script(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
750 use std::os::unix::fs::PermissionsExt;
751 let path = dir.join(name);
752 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
753 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
754 path
755 }
756
757 #[cfg(target_os = "linux")]
758 #[test]
759 fn fresh_probe_uses_exact_env_cwd_and_observes_availability_changes() {
760 let dir = tempfile::tempdir().unwrap();
761 let binary = fake_script(
762 dir.path(),
763 "systemd-run",
764 r#"
765[ "$DBUS_SESSION_BUS_ADDRESS" = fake:good ] || exit 1
766[ "$TERM" = user-term ] || exit 2
767[ "$SYNAPS_DAEMON" = 1 ] || exit 3
768[ "$SYNAPS_DAEMON_READY_FD" = 987 ] || exit 4
769[ "${HOME+x}" != x ] || exit 5
770[ "$PWD" = "$EXPECTED_CWD" ] || exit 6
771while [ "$1" != -- ]; do shift; done
772shift
773[ "$1" = /bin/true ] || exit 7
774exit 0
775"#,
776 );
777 let mut env = vec![];
778 for (k, v) in [
779 ("DBUS_SESSION_BUS_ADDRESS", "fake:good"),
780 ("TERM", "user-term"),
781 ("SYNAPS_DAEMON", "1"),
782 ("SYNAPS_DAEMON_READY_FD", "987"),
783 ("PATH", "/nonexistent"),
784 ] {
785 set_env(&mut env, k, v);
786 }
787 set_env(&mut env, "EXPECTED_CWD", dir.path().as_os_str());
788 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path()), ScopeKind::Tool, None).is_some());
789 set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:stale");
790 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path()), ScopeKind::Tool, None).is_none());
791 set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:good");
792 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path()), ScopeKind::Tool, None).is_some());
793 fake_script(dir.path(), "systemd-run", "exit 1");
795 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path()), ScopeKind::Tool, None).is_none());
796 env.retain(|(k, _)| k != "DBUS_SESSION_BUS_ADDRESS");
797 assert!(!probe(&binary, true, &env, Some(dir.path()), None));
798 }
799
800 #[cfg(target_os = "linux")]
801 #[test]
802 fn post_probe_failure_never_replays_and_exec_preserves_pid_and_stdio() {
803 use std::io::Write;
804 use std::process::{Command, Stdio};
805 let dir = tempfile::tempdir().unwrap();
806 let command = fake_script(
807 dir.path(),
808 "command",
809 "printf 'command=%s\\n' \"$$\"; read line; printf '%s\\n' \"$line\"; exit 23",
810 );
811 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
812 for started in [false, true] {
813 let binary = fake_script(
814 dir.path(),
815 "systemd-run",
816 &format!(
817 r#"
818while [ "$1" != -- ]; do shift; done
819shift
820[ "$1" = /bin/true ] && exit 0
821printf 'wrapper=%s\n' "$$"
822{}
823"#,
824 if started { "exec \"$@\"" } else { "exit 42" }
825 ),
826 );
827 let launcher = launcher_with_binary(&binary, true, &env, Some(dir.path()), ScopeKind::Tool, None).unwrap();
828 let (program, args) = launcher.wrap("test", command.as_os_str(), []);
829 let mut child = Command::new(program)
830 .args(args)
831 .env_clear()
832 .envs(env.clone())
833 .current_dir(dir.path())
834 .stdin(Stdio::piped())
835 .stdout(Stdio::piped())
836 .spawn()
837 .unwrap();
838 let pid = child.id();
839 if started {
840 child.stdin.take().unwrap().write_all(b"input\n").unwrap();
841 }
842 let output = child.wait_with_output().unwrap();
843 let text = String::from_utf8(output.stdout).unwrap();
844 assert_eq!(
845 text,
846 if started {
847 format!("wrapper={pid}\ncommand={pid}\ninput\n")
848 } else {
849 format!("wrapper={pid}\n")
850 }
851 );
852 assert_eq!(output.status.code(), Some(if started { 23 } else { 42 }));
853 }
854 }
855
856 #[cfg(unix)]
857 #[test]
858 fn missing_child_path_program_is_an_error_not_launcher_path_fallback() {
859 let dir = tempfile::tempdir().unwrap();
860 let env = vec![("PATH".into(), dir.path().as_os_str().to_owned())];
861 assert!(resolve_program("bash".into(), &env, Some(dir.path())).is_err());
862 }
863
864 #[test]
865 fn host_policy_is_not_selected_from_the_child_environment() {
866 let source = include_str!("process_scope.rs");
867 let selection = source
868 .split("pub fn launcher_for_effective_env")
869 .nth(1)
870 .unwrap()
871 .split("/// Compatibility convenience")
872 .next()
873 .unwrap();
874 assert!(selection.contains("std::env::var(\"SYNAPS_PROCESS_SCOPES\")"));
875 assert!(!selection.contains("env.iter()"));
876 assert!(!enabled_from(Some("off"), true));
877 }
878
879 #[test]
880 fn production_callers_snapshot_overrides_and_do_not_retry_wrapped_launches() {
881 let pty = include_str!("tools/shell/pty.rs");
882 let spawn = pty
883 .split("pub(crate) fn prepare_scope")
884 .nth(1)
885 .unwrap()
886 .split("// 4.")
887 .next()
888 .unwrap();
889 assert!(
890 spawn.find("set_env(&mut effective_env, \"TERM\"").unwrap()
891 < spawn.find("for (k, v) in env").unwrap()
892 );
893 assert!(
894 spawn.find("for (k, v) in env").unwrap()
895 < spawn.find("launcher_for_effective_env").unwrap()
896 );
897 assert!(spawn.contains("cmd.env_clear();"));
898 assert_eq!(spawn.matches(".spawn_command(cmd)").count(), 1);
899 let daemon = include_str!("daemon/mod.rs");
900 let spawn = daemon.split("pub fn spawn_detached(").nth(1).unwrap();
901 assert!(spawn.find("daemon_launch(").unwrap() < spawn.find("libc::pipe(").unwrap());
902 assert!(
903 spawn.find("libc::pipe(").unwrap()
904 < spawn
905 .find("set_env(&mut effective_env, READY_FD_ENV")
906 .unwrap()
907 );
908 assert!(spawn.find("child.kill()").unwrap() < spawn.find("take(64 * 1024)").unwrap());
909 assert!(spawn.contains("cmd.env_clear().envs(effective_env)"));
910 let bash = include_str!("tools/bash.rs");
911 let spawn = bash
912 .split("pub(crate) async fn run_shell_command")
913 .nth(1)
914 .unwrap()
915 .split("#[cfg(test)]")
916 .next()
917 .unwrap();
918 assert_eq!(spawn.matches("cmd.spawn()").count(), 1);
919 assert!(spawn.contains("_ = cancel.cancelled()"));
920 }
921
922 fn args_of(args: &[OsString]) -> Vec<String> {
923 args.iter().map(|a| a.to_string_lossy().into_owned()).collect()
924 }
925
926 #[test]
927 fn wrap_passes_the_slice_before_the_description_when_set() {
928 let l = ScopeLauncher {
929 systemd_run: PathBuf::from("/usr/bin/systemd-run"),
930 no_expand: false,
931 slice: Some("synaps_tools.slice".into()),
932 };
933 let (program, args) = l.wrap("synaps tool: bash", "bash", ["-c".into(), "echo hi".into()]);
934 assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
935 assert_eq!(
936 args_of(&args),
937 [
938 "--user",
939 "--scope",
940 "--quiet",
941 "--collect",
942 "--slice=synaps_tools.slice",
943 "--description=synaps tool: bash",
944 "--",
945 "bash",
946 "-c",
947 "echo hi",
948 ]
949 );
950 }
951
952 #[test]
953 fn each_kind_reads_its_own_slice_key() {
954 let mut c = crate::config::SynapsConfig::default();
955 assert_eq!(slice_for(ScopeKind::Tool, &c), None);
956 assert_eq!(slice_for(ScopeKind::Daemon, &c), None);
957 c.tool_scope_slice = Some("t_tools.slice".into());
958 assert_eq!(slice_for(ScopeKind::Tool, &c).as_deref(), Some("t_tools.slice"));
959 assert_eq!(slice_for(ScopeKind::Daemon, &c), None);
960 c.daemon_scope_slice = Some("d_daemon.slice".into());
961 assert_eq!(slice_for(ScopeKind::Daemon, &c).as_deref(), Some("d_daemon.slice"));
962 assert_eq!(slice_for(ScopeKind::Tool, &c).as_deref(), Some("t_tools.slice"));
963 assert_eq!(ScopeKind::Tool.slice_key(), "tool_scope_slice");
964 assert_eq!(ScopeKind::Daemon.slice_key(), "daemon_scope_slice");
965 }
966
967 #[cfg(target_os = "linux")]
968 #[test]
969 fn the_probe_runs_with_the_launch_slice_and_a_refused_slice_launches_direct() {
970 let dir = tempfile::tempdir().unwrap();
971 let fake = test_support::fake_systemd_run(dir.path());
972 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
973 assert!(launcher_with_binary(&fake, false, &env, Some(dir.path()), ScopeKind::Tool, None).is_some());
975 assert_eq!(
976 test_support::recorded(dir.path(), "probe.args"),
977 ["--user", "--scope", "--quiet", "--collect", "--description=synaps scope probe", "--", "/bin/true"]
978 );
979 let slice = Some("synaps_tools.slice".to_string());
981 let l = launcher_with_binary(&fake, false, &env, Some(dir.path()), ScopeKind::Tool, slice).unwrap();
982 assert_eq!(
983 test_support::recorded(dir.path(), "probe.args"),
984 [
985 "--user",
986 "--scope",
987 "--quiet",
988 "--collect",
989 "--slice=synaps_tools.slice",
990 "--description=synaps scope probe",
991 "--",
992 "/bin/true",
993 ]
994 );
995 assert!(args_of(&l.wrap("t", "/bin/echo", []).1).contains(&"--slice=synaps_tools.slice".to_string()));
996 let refusing = fake_script(
999 dir.path(),
1000 "refusing-systemd-run",
1001 r#"for a in "$@"; do case "$a" in --slice=*) exit 1;; esac; done
1002exit 0"#,
1003 );
1004 let refused = Some("refused-probe-test.slice".to_string());
1005 assert!(launcher_with_binary(&refusing, false, &env, Some(dir.path()), ScopeKind::Tool, refused).is_none());
1006 assert!(launcher_with_binary(&refusing, false, &env, Some(dir.path()), ScopeKind::Tool, None).is_some());
1007 }
1008
1009 #[cfg(target_os = "linux")]
1010 #[test]
1011 fn a_refused_slice_warns_once_per_name() {
1012 assert!(first_refusal("warn-once-test-a.slice"));
1013 assert!(!first_refusal("warn-once-test-a.slice"));
1014 assert!(first_refusal("warn-once-test-b.slice"));
1015 }
1016
1017 #[cfg(target_os = "linux")]
1021 #[test]
1022 #[serial_test::serial(synaps_base_dir)]
1023 fn each_kind_launches_in_its_configured_slice() {
1024 let base = crate::test_env::BaseDirGuard::new();
1025 let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
1026 let dir = tempfile::tempdir().unwrap();
1027 let fake = test_support::fake_systemd_run(dir.path());
1028 let env: Vec<(OsString, OsString)> = test_support::fake_env(&fake)
1029 .into_iter()
1030 .map(|(k, v)| (k.into(), v.into()))
1031 .collect();
1032 let slices = |kind| {
1033 launcher_for_effective_env(&env, Some(dir.path()), kind).map(|l| {
1034 args_of(&l.wrap("t", "/bin/echo", []).1)
1035 .into_iter()
1036 .filter(|a| a.starts_with("--slice"))
1037 .collect::<Vec<_>>()
1038 })
1039 };
1040 assert_eq!(slices(ScopeKind::Tool), Some(vec![]));
1041 assert_eq!(slices(ScopeKind::Daemon), Some(vec![]));
1042 let config = base.path().join("config");
1043 std::fs::write(&config, "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1044 assert_eq!(slices(ScopeKind::Tool), Some(vec!["--slice=t_tools.slice".to_string()]));
1045 assert_eq!(slices(ScopeKind::Daemon), Some(vec!["--slice=d_daemon.slice".to_string()]));
1046 std::fs::write(&config, "process_scopes = off\ntool_scope_slice = t_tools.slice\n").unwrap();
1047 assert_eq!(slices(ScopeKind::Tool), None);
1048 }
1049
1050 #[test]
1056 fn the_fake_systemd_run_seam_is_test_only() {
1057 let compact: String = include_str!("process_scope.rs")
1058 .chars()
1059 .filter(|c| !c.is_whitespace())
1060 .collect();
1061 let name = ["test_fake", "_systemd_run"].concat();
1063 let gate = "#[cfg(test)]";
1064 let definitions: Vec<_> = compact.match_indices(&format!("fn{name}(")).collect();
1065 assert_eq!(definitions.len(), 1, "one definition of the seam");
1066 assert!(compact[..definitions[0].0].ends_with(gate), "the definition is cfg(test)");
1067 let calls: Vec<_> = compact.match_indices(&format!("={name}(")).collect();
1068 assert_eq!(calls.len(), 1, "one call of the seam");
1069 assert!(
1070 compact[..calls[0].0].ends_with(&format!("{gate}letfake")),
1071 "the call is cfg(test)"
1072 );
1073 let production = compact.split("#[cfg(test)]modtests{").next().unwrap();
1076 let env_name = ["SYNAPS_TEST_FAKE", "_SYSTEMD_RUN"].concat();
1077 assert_eq!(production.matches(&format!("\"{env_name}\"")).count(), 1, "named only in the seam");
1078 }
1079
1080 #[test]
1081 fn a_failed_probe_warning_does_not_blame_the_slice_alone() {
1082 let w = probe_failed_warning("synaps_tools.slice");
1083 assert!(w.contains("user manager unreachable"), "{w}");
1084 assert!(w.contains("or slice synaps_tools.slice refused"), "{w}");
1085 assert!(w.contains("start without a scope"), "{w}");
1086 }
1087
1088 #[test]
1089 fn a_slice_with_no_limit_set_is_reported_and_one_limit_is_enough() {
1090 let missing = "LoadState=loaded\nFragmentPath=\nCPUWeight=[not set]\nCPUQuotaPerSecUSec=infinity\n\
1092 MemoryHigh=infinity\nMemoryMax=infinity\nMemorySwapMax=infinity\nTasksMax=infinity\n";
1093 assert_eq!(slice_without_limits(missing), Some(false));
1094 let with_unit = missing.replace("FragmentPath=\n", "FragmentPath=/home/u/.config/systemd/user/synaps_tools.slice\n");
1095 assert_eq!(slice_without_limits(&with_unit), Some(true));
1096 let old = missing.replace("[not set]", "18446744073709551615").replace("TasksMax=infinity", "TasksMax=");
1098 assert_eq!(slice_without_limits(&old), Some(false));
1099 for (from, to) in [
1101 ("CPUWeight=[not set]", "CPUWeight=20"),
1102 ("CPUQuotaPerSecUSec=infinity", "CPUQuotaPerSecUSec=2s"),
1103 ("MemoryHigh=infinity", "MemoryHigh=8589934592"),
1104 ("MemoryMax=infinity", "MemoryMax=12884901888"),
1105 ("MemorySwapMax=infinity", "MemorySwapMax=0"),
1106 ("TasksMax=infinity", "TasksMax=512"),
1107 ] {
1108 assert_eq!(slice_without_limits(&missing.replace(from, to)), None, "{to}");
1109 }
1110 assert_eq!(slice_without_limits(""), None);
1113 assert_eq!(slice_without_limits(&missing.replace("TasksMax=infinity\n", "")), None);
1114 let w = no_limits_warning("synaps_tools.slice", false);
1115 assert!(w.contains("synaps_tools.slice") && w.contains("typo") && w.contains("daemon-reload"), "{w}");
1116 assert!(no_limits_warning("synaps_tools.slice", true).contains("its unit sets none"));
1117 }
1118
1119 #[cfg(target_os = "linux")]
1120 #[test]
1121 fn slice_limits_are_looked_up_once_per_name_off_the_launch_path() {
1122 assert!(first_limits_check("limits-once-test-a.slice"));
1123 assert!(!first_limits_check("limits-once-test-a.slice"));
1124 assert!(first_limits_check("limits-once-test-b.slice"));
1125 assert!(first_refusal("limits-once-test-a.slice"));
1128 }
1129
1130 #[cfg(target_os = "linux")]
1134 #[test]
1135 fn a_launch_with_a_slice_asks_the_manager_for_its_limits_once() {
1136 let dir = tempfile::tempdir().unwrap();
1137 let fake = test_support::fake_systemd_run(dir.path());
1138 let log = dir.path().join("systemctl.log");
1139 fake_script(
1140 dir.path(),
1141 "systemctl",
1142 &format!(
1143 "printf '%s %s\\n' \"$DBUS_SESSION_BUS_ADDRESS\" \"$*\" >> '{}'\n\
1144 printf 'FragmentPath=\\nCPUWeight=[not set]\\nCPUQuotaPerSecUSec=infinity\\n'\n\
1145 printf 'MemoryHigh=infinity\\nMemoryMax=infinity\\nMemorySwapMax=infinity\\nTasksMax=infinity\\n'",
1146 log.display()
1147 ),
1148 );
1149 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:limits".into())];
1150 let read_log = || std::fs::read_to_string(&log).unwrap_or_default();
1151 assert!(launcher_with_binary(&fake, false, &env, None, ScopeKind::Tool, None).is_some());
1153 let slice = "limits-e2e-test.slice".to_string();
1154 for _ in 0..3 {
1155 assert!(launcher_with_binary(&fake, false, &env, None, ScopeKind::Tool, Some(slice.clone())).is_some());
1156 }
1157 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1158 while read_log().is_empty() && std::time::Instant::now() < deadline {
1159 std::thread::sleep(std::time::Duration::from_millis(10));
1160 }
1161 std::thread::sleep(std::time::Duration::from_millis(200));
1163 assert_eq!(
1164 read_log().lines().collect::<Vec<_>>(),
1165 [format!("fake:limits --user show -p {SLICE_LIMIT_PROPERTIES} -p FragmentPath -- {slice}")]
1166 );
1167 let systemctl = dir.path().join("systemctl");
1169 assert_eq!(query_slice_without_limits(&systemctl, &env, &slice), Some(false));
1170 fake_script(dir.path(), "systemctl", "exit 1");
1171 assert_eq!(query_slice_without_limits(&systemctl, &env, &slice), None);
1172 }
1173}
1174
1175#[cfg(all(test, target_os = "linux"))]
1178pub(crate) mod test_support {
1179 use std::path::{Path, PathBuf};
1180
1181 pub(crate) fn fake_systemd_run(dir: &Path) -> PathBuf {
1185 use std::os::unix::fs::PermissionsExt;
1186 let path = dir.join("systemd-run");
1187 let body = format!(
1188 "#!/bin/sh\nfor a in \"$@\"; do last=$a; done\n\
1189 if [ \"$last\" = /bin/true ]; then printf '%s\\n' \"$@\" > '{probe}'; exit 0; fi\n\
1190 printf '%s\\n' \"$@\" > '{launch}'\n\
1191 while [ \"$1\" != -- ]; do shift; done\nshift\nexec \"$@\"\n",
1192 probe = dir.join("probe.args").display(),
1193 launch = dir.join("launch.args").display(),
1194 );
1195 std::fs::write(&path, body).unwrap();
1196 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
1197 path
1198 }
1199
1200 pub(crate) fn recorded(dir: &Path, which: &str) -> Vec<String> {
1202 std::fs::read_to_string(dir.join(which))
1203 .unwrap_or_default()
1204 .lines()
1205 .map(str::to_owned)
1206 .collect()
1207 }
1208
1209 pub(crate) fn fake_env(fake: &Path) -> Vec<(String, String)> {
1212 vec![
1213 ("DBUS_SESSION_BUS_ADDRESS".into(), "fake:slice-test".into()),
1214 ("SYNAPS_TEST_FAKE_SYSTEMD_RUN".into(), fake.display().to_string()),
1215 ]
1216 }
1217}