1use std::ffi::OsString;
28use std::path::PathBuf;
29#[cfg(target_os = "linux")]
30use std::sync::OnceLock;
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct ScopeLauncher {
35 systemd_run: PathBuf,
36 no_expand: bool,
39}
40
41const EXPAND_ENVIRONMENT_SINCE: u32 = 254;
50
51fn scope_options(description: &str, no_expand: bool) -> Vec<OsString> {
54 let mut argv: Vec<OsString> = vec![
55 "--user".into(),
56 "--scope".into(),
57 "--quiet".into(),
58 "--collect".into(),
61 ];
62 if no_expand {
63 argv.push("--expand-environment=no".into());
67 }
68 argv.push(format!("--description={description}").into());
69 argv
70}
71
72impl ScopeLauncher {
73 pub fn wrap(
76 &self,
77 description: &str,
78 program: impl Into<OsString>,
79 args: impl IntoIterator<Item = OsString>,
80 ) -> (OsString, Vec<OsString>) {
81 let mut argv = scope_options(description, self.no_expand);
82 argv.push("--".into());
83 argv.push(program.into());
84 argv.extend(args);
85 (self.systemd_run.clone().into_os_string(), argv)
86 }
87}
88
89fn parse_systemd_version(output: &str) -> Option<u32> {
91 let rest = output.lines().next()?.trim().strip_prefix("systemd ")?;
92 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
93 digits.parse().ok()
94}
95
96fn no_expand_for_version(version: Option<u32>) -> Option<bool> {
100 version.map(|v| v >= EXPAND_ENVIRONMENT_SINCE)
101}
102
103fn enabled_from(env_value: Option<&str>, config_value: bool) -> bool {
106 match env_value.map(|v| v.trim().to_ascii_lowercase()) {
107 Some(v) if matches!(v.as_str(), "0" | "off" | "false" | "no") => false,
108 Some(v) if matches!(v.as_str(), "1" | "on" | "true" | "yes" | "auto") => true,
109 _ => config_value,
110 }
111}
112
113pub fn inherited_env() -> Vec<(OsString, OsString)> {
115 std::env::vars_os().collect()
116}
117
118pub fn set_env(env: &mut Vec<(OsString, OsString)>, key: &str, value: impl Into<OsString>) {
119 env.retain(|(k, _)| k != key);
120 env.push((key.into(), value.into()));
121}
122
123#[cfg(target_os = "linux")]
124fn find_systemd_run() -> Option<PathBuf> {
125 ["/usr/bin/systemd-run", "/bin/systemd-run"]
128 .into_iter()
129 .map(PathBuf::from)
130 .find(|p| p.is_file())
131}
132
133#[cfg(target_os = "linux")]
136fn probe(
137 systemd_run: &std::path::Path,
138 no_expand: bool,
139 env: &[(OsString, OsString)],
140 cwd: Option<&std::path::Path>,
141) -> bool {
142 if !env
143 .iter()
144 .any(|(k, v)| (k == "XDG_RUNTIME_DIR" || k == "DBUS_SESSION_BUS_ADDRESS") && !v.is_empty())
145 {
146 return false;
147 }
148 let mut cmd = std::process::Command::new(systemd_run);
149 cmd.env_clear().envs(env.iter().cloned());
150 if let Some(cwd) = cwd {
151 cmd.current_dir(cwd);
152 }
153 cmd.args(scope_options("synaps scope probe", no_expand))
154 .args(["--", "/bin/true"])
155 .stdout(std::process::Stdio::null());
156 run_bounded(cmd).is_some_and(|(status, _)| status.success())
157}
158
159#[cfg(target_os = "linux")]
161fn systemd_run_version(systemd_run: &std::path::Path) -> Option<u32> {
162 let mut cmd = std::process::Command::new(systemd_run);
163 cmd.env_clear()
164 .env("LC_ALL", "C")
165 .env("SYSTEMD_PAGER", "")
166 .arg("--version")
167 .stdout(std::process::Stdio::piped());
168 let (status, stdout) = run_bounded(cmd)?;
169 status.success().then_some(())?;
170 parse_systemd_version(&String::from_utf8_lossy(&stdout))
171}
172
173#[cfg(target_os = "linux")]
177fn run_bounded(mut cmd: std::process::Command) -> Option<(std::process::ExitStatus, Vec<u8>)> {
178 use std::io::Read;
179 use std::os::unix::process::CommandExt;
180 use std::time::{Duration, Instant};
181 unsafe {
183 cmd.pre_exec(|| {
184 if libc::setsid() < 0 {
185 return Err(std::io::Error::last_os_error());
186 }
187 Ok(())
188 });
189 }
190 let mut child = cmd
191 .stdin(std::process::Stdio::null())
192 .stderr(std::process::Stdio::null())
193 .spawn()
194 .ok()?;
195 let deadline = Instant::now() + Duration::from_secs(3);
196 loop {
197 match child.try_wait() {
198 Ok(Some(status)) => {
199 let mut out = Vec::new();
201 if let Some(mut stdout) = child.stdout.take() {
202 let _ = stdout.by_ref().take(64 * 1024).read_to_end(&mut out);
203 }
204 return Some((status, out));
205 }
206 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)),
207 _ => {
208 unsafe {
210 libc::killpg(child.id() as libc::pid_t, libc::SIGKILL);
211 }
212 let _ = child.kill();
213 let cleanup_deadline = Instant::now() + Duration::from_millis(100);
214 while matches!(child.try_wait(), Ok(None)) && Instant::now() < cleanup_deadline {
215 std::thread::sleep(Duration::from_millis(5));
216 }
217 std::thread::spawn(move || {
220 let _ = child.wait();
221 });
222 return None;
223 }
224 }
225 }
226}
227
228pub fn launcher_for_effective_env(
234 env: &[(OsString, OsString)],
235 cwd: Option<&std::path::Path>,
236) -> Option<ScopeLauncher> {
237 #[cfg(any(test, feature = "testing"))]
241 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") {
242 return None;
243 }
244 if !enabled_from(
247 std::env::var("SYNAPS_PROCESS_SCOPES").ok().as_deref(),
248 crate::config::load_config().process_scopes,
249 ) {
250 return None;
251 }
252 #[cfg(target_os = "linux")]
253 {
254 static BINARY: OnceLock<Option<PathBuf>> = OnceLock::new();
255 let binary = BINARY.get_or_init(find_systemd_run).as_ref()?;
256 static NO_EXPAND: OnceLock<bool> = OnceLock::new();
259 let no_expand = match NO_EXPAND.get() {
260 Some(v) => *v,
261 None => {
262 let v = no_expand_for_version(systemd_run_version(binary))?;
263 *NO_EXPAND.get_or_init(|| v)
264 }
265 };
266 launcher_with_binary(binary, no_expand, env, cwd)
267 }
268 #[cfg(not(target_os = "linux"))]
269 {
270 let _ = cwd;
271 None
272 }
273}
274
275#[cfg(target_os = "linux")]
276fn launcher_with_binary(
277 binary: &std::path::Path,
278 no_expand: bool,
279 env: &[(OsString, OsString)],
280 cwd: Option<&std::path::Path>,
281) -> Option<ScopeLauncher> {
282 probe(binary, no_expand, env, cwd).then(|| ScopeLauncher {
283 systemd_run: binary.to_owned(),
284 no_expand,
285 })
286}
287
288pub fn launcher_for_env(env: Option<&[(String, String)]>) -> Option<ScopeLauncher> {
290 let env = env
291 .map(|e| e.iter().map(|(k, v)| (k.into(), v.into())).collect())
292 .unwrap_or_else(inherited_env);
293 launcher_for_effective_env(&env, None)
294}
295
296pub fn resolve_program(
299 program: OsString,
300 env: &[(OsString, OsString)],
301 cwd: Option<&std::path::Path>,
302) -> std::io::Result<OsString> {
303 #[cfg(unix)]
304 {
305 use std::os::unix::fs::PermissionsExt;
306 let base = cwd
307 .map(PathBuf::from)
308 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
309 let base = if base.is_absolute() {
310 base
311 } else {
312 std::env::current_dir().unwrap_or_default().join(base)
313 };
314 if std::path::Path::new(&program).components().count() > 1 {
315 return Ok(base.join(program).into_os_string());
316 }
317 let path = env
318 .iter()
319 .find(|(k, _)| k == "PATH")
320 .map(|(_, v)| v.clone())
321 .unwrap_or_else(|| "/bin:/usr/bin".into());
322 for dir in std::env::split_paths(&path) {
323 let candidate = base.join(dir).join(&program);
324 if candidate
325 .metadata()
326 .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
327 {
328 return Ok(candidate.into_os_string());
329 }
330 }
331 }
332 Err(std::io::Error::new(
333 std::io::ErrorKind::NotFound,
334 format!("program not found in child PATH: {program:?}"),
335 ))
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 fn wrapped(no_expand: bool, script: &str) -> (OsString, Vec<String>) {
343 let l = ScopeLauncher {
344 systemd_run: PathBuf::from("/usr/bin/systemd-run"),
345 no_expand,
346 };
347 let (program, args) = l.wrap("synaps tool: bash", "bash", ["-c".into(), script.into()]);
348 let args = args
349 .iter()
350 .map(|a| a.to_string_lossy().into_owned())
351 .collect();
352 (program, args)
353 }
354
355 #[test]
356 fn wrap_disables_expansion_when_systemd_run_supports_it() {
357 let (program, args) = wrapped(true, "echo 'D:${x}' $HOME");
358 assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
359 assert_eq!(
360 args,
361 [
362 "--user",
363 "--scope",
364 "--quiet",
365 "--collect",
366 "--expand-environment=no",
367 "--description=synaps tool: bash",
368 "--",
369 "bash",
370 "-c",
371 "echo 'D:${x}' $HOME",
372 ]
373 );
374 }
375
376 #[test]
377 fn wrap_never_passes_the_flag_to_a_systemd_run_without_it() {
378 let (program, args) = wrapped(false, "echo hi");
379 assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
380 assert_eq!(
381 args,
382 [
383 "--user",
384 "--scope",
385 "--quiet",
386 "--collect",
387 "--description=synaps tool: bash",
388 "--",
389 "bash",
390 "-c",
391 "echo hi",
392 ]
393 );
394 assert!(!args.iter().any(|a| a.starts_with("--expand-environment")));
395 }
396
397 #[test]
398 fn version_decides_the_flag_and_unknown_versions_decide_nothing() {
399 assert_eq!(
400 parse_systemd_version("systemd 259 (259.5-0ubuntu3.4)\n+PAM +AUDIT"),
401 Some(259)
402 );
403 assert_eq!(parse_systemd_version("systemd 254 (254.5-1)"), Some(254));
404 assert_eq!(parse_systemd_version("systemd 249\n"), Some(249));
405 assert_eq!(parse_systemd_version("systemd 219"), Some(219));
406 assert_eq!(parse_systemd_version(""), None);
407 assert_eq!(parse_systemd_version("something else 300"), None);
408 assert_eq!(parse_systemd_version("systemd x"), None);
409 assert_eq!(no_expand_for_version(Some(259)), Some(true));
410 assert_eq!(no_expand_for_version(Some(254)), Some(true));
411 assert_eq!(no_expand_for_version(Some(253)), Some(false));
412 assert_eq!(no_expand_for_version(Some(219)), Some(false));
413 assert_eq!(no_expand_for_version(None), None);
414 }
415
416 #[cfg(target_os = "linux")]
417 #[test]
418 fn version_query_and_probe_use_exactly_the_launch_options() {
419 let dir = tempfile::tempdir().unwrap();
420 let binary = fake_script(
422 dir.path(),
423 "systemd-run",
424 r#"
425[ "$1" = --version ] && { echo 'systemd 249 (249.11-0ubuntu3)'; exit 0; }
426for a in "$@"; do case "$a" in --expand-environment=*) exit 1;; --) break;; esac; done
427exit 0
428"#,
429 );
430 assert_eq!(systemd_run_version(&binary), Some(249));
431 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
432 assert!(launcher_with_binary(&binary, false, &env, None).is_some());
433 assert!(launcher_with_binary(&binary, true, &env, None).is_none());
436 fake_script(dir.path(), "systemd-run", "exit 1");
437 assert_eq!(systemd_run_version(&binary), None);
438 fake_script(dir.path(), "systemd-run", "echo 'systemd 258 (258)'");
439 assert_eq!(systemd_run_version(&binary), Some(258));
440 }
441
442 #[cfg(target_os = "linux")]
446 #[test]
447 fn real_scope_passes_dollar_braces_through_verbatim() {
448 if std::env::var("SYNAPS_PROCESS_SCOPES").is_ok_and(|v| !enabled_from(Some(&v), true)) {
449 eprintln!("skip: SYNAPS_PROCESS_SCOPES is off");
450 return;
451 }
452 let Some(binary) = find_systemd_run() else {
453 eprintln!("skip: no systemd-run");
454 return;
455 };
456 let Some(no_expand) = no_expand_for_version(systemd_run_version(&binary)) else {
457 eprintln!("skip: systemd-run --version unusable");
458 return;
459 };
460 let mut env = inherited_env();
461 set_env(&mut env, "x", "EXPANDED");
462 let Some(launcher) = launcher_with_binary(&binary, no_expand, &env, None) else {
463 eprintln!("skip: no reachable systemd user manager");
464 return;
465 };
466 let payload = ["D:${x}", "$x", "'${x:-dflt}'", "`${a}${b}`", "$$", "${}"];
467 let mut args: Vec<OsString> =
468 vec!["-c".into(), "printf '%s\\n' \"$@\"".into(), "sh".into()];
469 args.extend(payload.iter().map(OsString::from));
470 let (program, argv) = launcher.wrap("synaps test: no expand", "/bin/sh", args);
471 let out = std::process::Command::new(program)
472 .args(argv)
473 .env_clear()
474 .envs(env)
475 .stdin(std::process::Stdio::null())
476 .output()
477 .unwrap();
478 assert!(
479 out.status.success(),
480 "{}",
481 String::from_utf8_lossy(&out.stderr)
482 );
483 let stdout = String::from_utf8(out.stdout).unwrap();
484 assert_eq!(stdout.lines().collect::<Vec<_>>(), payload);
485 assert!(
486 !String::from_utf8_lossy(&out.stderr).contains("environment variable"),
487 "{}",
488 String::from_utf8_lossy(&out.stderr)
489 );
490 }
491
492 #[test]
493 fn ordinary_test_build_does_not_probe_host_manager() {
494 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() == Ok("1") {
495 return;
496 }
497 let env = vec![(
498 "DBUS_SESSION_BUS_ADDRESS".into(),
499 "fake:must-not-probe".into(),
500 )];
501 assert!(launcher_for_effective_env(&env, None).is_none());
502 }
503
504 #[test]
505 fn env_variable_overrides_config_and_unknown_values_fall_back_to_config() {
506 assert!(enabled_from(None, true));
507 assert!(!enabled_from(None, false));
508 for off in ["0", "off", "OFF", " false ", "no"] {
509 assert!(!enabled_from(Some(off), true), "{off:?}");
510 }
511 for on in ["1", "on", "true", "yes", "auto"] {
512 assert!(enabled_from(Some(on), false), "{on:?}");
513 }
514 assert!(enabled_from(Some("maybe"), true));
515 assert!(!enabled_from(Some("maybe"), false));
516 }
517
518 #[cfg(target_os = "linux")]
521 fn fake_script(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
522 use std::os::unix::fs::PermissionsExt;
523 let path = dir.join(name);
524 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
525 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
526 path
527 }
528
529 #[cfg(target_os = "linux")]
530 #[test]
531 fn fresh_probe_uses_exact_env_cwd_and_observes_availability_changes() {
532 let dir = tempfile::tempdir().unwrap();
533 let binary = fake_script(
534 dir.path(),
535 "systemd-run",
536 r#"
537[ "$DBUS_SESSION_BUS_ADDRESS" = fake:good ] || exit 1
538[ "$TERM" = user-term ] || exit 2
539[ "$SYNAPS_DAEMON" = 1 ] || exit 3
540[ "$SYNAPS_DAEMON_READY_FD" = 987 ] || exit 4
541[ "${HOME+x}" != x ] || exit 5
542[ "$PWD" = "$EXPECTED_CWD" ] || exit 6
543while [ "$1" != -- ]; do shift; done
544shift
545[ "$1" = /bin/true ] || exit 7
546exit 0
547"#,
548 );
549 let mut env = vec![];
550 for (k, v) in [
551 ("DBUS_SESSION_BUS_ADDRESS", "fake:good"),
552 ("TERM", "user-term"),
553 ("SYNAPS_DAEMON", "1"),
554 ("SYNAPS_DAEMON_READY_FD", "987"),
555 ("PATH", "/nonexistent"),
556 ] {
557 set_env(&mut env, k, v);
558 }
559 set_env(&mut env, "EXPECTED_CWD", dir.path().as_os_str());
560 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_some());
561 set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:stale");
562 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_none());
563 set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:good");
564 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_some());
565 fake_script(dir.path(), "systemd-run", "exit 1");
567 assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_none());
568 env.retain(|(k, _)| k != "DBUS_SESSION_BUS_ADDRESS");
569 assert!(!probe(&binary, true, &env, Some(dir.path())));
570 }
571
572 #[cfg(target_os = "linux")]
573 #[test]
574 fn post_probe_failure_never_replays_and_exec_preserves_pid_and_stdio() {
575 use std::io::Write;
576 use std::process::{Command, Stdio};
577 let dir = tempfile::tempdir().unwrap();
578 let command = fake_script(
579 dir.path(),
580 "command",
581 "printf 'command=%s\\n' \"$$\"; read line; printf '%s\\n' \"$line\"; exit 23",
582 );
583 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
584 for started in [false, true] {
585 let binary = fake_script(
586 dir.path(),
587 "systemd-run",
588 &format!(
589 r#"
590while [ "$1" != -- ]; do shift; done
591shift
592[ "$1" = /bin/true ] && exit 0
593printf 'wrapper=%s\n' "$$"
594{}
595"#,
596 if started { "exec \"$@\"" } else { "exit 42" }
597 ),
598 );
599 let launcher = launcher_with_binary(&binary, true, &env, Some(dir.path())).unwrap();
600 let (program, args) = launcher.wrap("test", command.as_os_str(), []);
601 let mut child = Command::new(program)
602 .args(args)
603 .env_clear()
604 .envs(env.clone())
605 .current_dir(dir.path())
606 .stdin(Stdio::piped())
607 .stdout(Stdio::piped())
608 .spawn()
609 .unwrap();
610 let pid = child.id();
611 if started {
612 child.stdin.take().unwrap().write_all(b"input\n").unwrap();
613 }
614 let output = child.wait_with_output().unwrap();
615 let text = String::from_utf8(output.stdout).unwrap();
616 assert_eq!(
617 text,
618 if started {
619 format!("wrapper={pid}\ncommand={pid}\ninput\n")
620 } else {
621 format!("wrapper={pid}\n")
622 }
623 );
624 assert_eq!(output.status.code(), Some(if started { 23 } else { 42 }));
625 }
626 }
627
628 #[cfg(unix)]
629 #[test]
630 fn missing_child_path_program_is_an_error_not_launcher_path_fallback() {
631 let dir = tempfile::tempdir().unwrap();
632 let env = vec![("PATH".into(), dir.path().as_os_str().to_owned())];
633 assert!(resolve_program("bash".into(), &env, Some(dir.path())).is_err());
634 }
635
636 #[test]
637 fn host_policy_is_not_selected_from_the_child_environment() {
638 let source = include_str!("process_scope.rs");
639 let selection = source
640 .split("pub fn launcher_for_effective_env")
641 .nth(1)
642 .unwrap()
643 .split("/// Compatibility convenience")
644 .next()
645 .unwrap();
646 assert!(selection.contains("std::env::var(\"SYNAPS_PROCESS_SCOPES\")"));
647 assert!(!selection.contains("env.iter()"));
648 assert!(!enabled_from(Some("off"), true));
649 }
650
651 #[test]
652 fn production_callers_snapshot_overrides_and_do_not_retry_wrapped_launches() {
653 let pty = include_str!("tools/shell/pty.rs");
654 let spawn = pty
655 .split("pub(crate) fn prepare_scope")
656 .nth(1)
657 .unwrap()
658 .split("// 4.")
659 .next()
660 .unwrap();
661 assert!(
662 spawn.find("set_env(&mut effective_env, \"TERM\"").unwrap()
663 < spawn.find("for (k, v) in env").unwrap()
664 );
665 assert!(
666 spawn.find("for (k, v) in env").unwrap()
667 < spawn.find("launcher_for_effective_env").unwrap()
668 );
669 assert!(spawn.contains("cmd.env_clear();"));
670 assert_eq!(spawn.matches(".spawn_command(cmd)").count(), 1);
671 let daemon = include_str!("daemon/mod.rs");
672 let spawn = daemon.split("pub fn spawn_detached(").nth(1).unwrap();
673 assert!(
674 spawn.find("launcher_for_effective_env").unwrap() < spawn.find("libc::pipe(").unwrap()
675 );
676 assert!(
677 spawn.find("libc::pipe(").unwrap()
678 < spawn
679 .find("set_env(&mut effective_env, READY_FD_ENV")
680 .unwrap()
681 );
682 assert!(spawn.find("child.kill()").unwrap() < spawn.find("take(64 * 1024)").unwrap());
683 assert!(spawn.contains("cmd.env_clear().envs(effective_env)"));
684 let bash = include_str!("tools/bash.rs");
685 let spawn = bash
686 .split("pub(crate) async fn run_shell_command")
687 .nth(1)
688 .unwrap()
689 .split("#[cfg(test)]")
690 .next()
691 .unwrap();
692 assert_eq!(spawn.matches("cmd.spawn()").count(), 1);
693 assert!(spawn.contains("_ = cancel.cancelled()"));
694 }
695}