1use std::ffi::OsString;
24use std::path::PathBuf;
25#[cfg(target_os = "linux")]
26use std::sync::OnceLock;
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct ScopeLauncher {
31 systemd_run: PathBuf,
32}
33
34impl ScopeLauncher {
35 pub fn wrap(
38 &self,
39 description: &str,
40 program: impl Into<OsString>,
41 args: impl IntoIterator<Item = OsString>,
42 ) -> (OsString, Vec<OsString>) {
43 let mut argv: Vec<OsString> = vec![
44 "--user".into(),
45 "--scope".into(),
46 "--quiet".into(),
47 "--collect".into(),
50 format!("--description={description}").into(),
51 "--".into(),
52 program.into(),
53 ];
54 argv.extend(args);
55 (self.systemd_run.clone().into_os_string(), argv)
56 }
57}
58
59fn enabled_from(env_value: Option<&str>, config_value: bool) -> bool {
62 match env_value.map(|v| v.trim().to_ascii_lowercase()) {
63 Some(v) if matches!(v.as_str(), "0" | "off" | "false" | "no") => false,
64 Some(v) if matches!(v.as_str(), "1" | "on" | "true" | "yes" | "auto") => true,
65 _ => config_value,
66 }
67}
68
69pub fn inherited_env() -> Vec<(OsString, OsString)> {
71 std::env::vars_os().collect()
72}
73
74pub fn set_env(env: &mut Vec<(OsString, OsString)>, key: &str, value: impl Into<OsString>) {
75 env.retain(|(k, _)| k != key);
76 env.push((key.into(), value.into()));
77}
78
79#[cfg(target_os = "linux")]
80fn find_systemd_run() -> Option<PathBuf> {
81 ["/usr/bin/systemd-run", "/bin/systemd-run"]
84 .into_iter()
85 .map(PathBuf::from)
86 .find(|p| p.is_file())
87}
88
89#[cfg(target_os = "linux")]
92fn probe(
93 systemd_run: &std::path::Path,
94 env: &[(OsString, OsString)],
95 cwd: Option<&std::path::Path>,
96) -> bool {
97 use std::time::{Duration, Instant};
98 if !env
99 .iter()
100 .any(|(k, v)| (k == "XDG_RUNTIME_DIR" || k == "DBUS_SESSION_BUS_ADDRESS") && !v.is_empty())
101 {
102 return false;
103 }
104 use std::os::unix::process::CommandExt;
105 let mut cmd = std::process::Command::new(systemd_run);
106 unsafe {
108 cmd.pre_exec(|| {
109 if libc::setsid() < 0 {
110 return Err(std::io::Error::last_os_error());
111 }
112 Ok(())
113 });
114 }
115 cmd.env_clear().envs(env.iter().cloned());
116 if let Some(cwd) = cwd {
117 cmd.current_dir(cwd);
118 }
119 let Ok(mut child) = cmd
120 .args([
121 "--user",
122 "--scope",
123 "--quiet",
124 "--collect",
125 "--description=synaps scope probe",
126 "--",
127 "/bin/true",
128 ])
129 .stdin(std::process::Stdio::null())
130 .stdout(std::process::Stdio::null())
131 .stderr(std::process::Stdio::null())
132 .spawn()
133 else {
134 return false;
135 };
136 let deadline = Instant::now() + Duration::from_secs(3);
137 loop {
138 match child.try_wait() {
139 Ok(Some(status)) => return status.success(),
140 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)),
141 _ => {
142 unsafe {
144 libc::killpg(child.id() as libc::pid_t, libc::SIGKILL);
145 }
146 let _ = child.kill();
147 let cleanup_deadline = Instant::now() + Duration::from_millis(100);
148 while matches!(child.try_wait(), Ok(None)) && Instant::now() < cleanup_deadline {
149 std::thread::sleep(Duration::from_millis(5));
150 }
151 std::thread::spawn(move || {
154 let _ = child.wait();
155 });
156 return false;
157 }
158 }
159 }
160}
161
162pub fn launcher_for_effective_env(
168 env: &[(OsString, OsString)],
169 cwd: Option<&std::path::Path>,
170) -> Option<ScopeLauncher> {
171 #[cfg(any(test, feature = "testing"))]
175 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") {
176 return None;
177 }
178 if !enabled_from(
181 std::env::var("SYNAPS_PROCESS_SCOPES").ok().as_deref(),
182 crate::config::load_config().process_scopes,
183 ) {
184 return None;
185 }
186 #[cfg(target_os = "linux")]
187 {
188 static BINARY: OnceLock<Option<PathBuf>> = OnceLock::new();
189 let binary = BINARY.get_or_init(find_systemd_run).as_ref()?;
190 launcher_with_binary(binary, env, cwd)
191 }
192 #[cfg(not(target_os = "linux"))]
193 {
194 let _ = cwd;
195 None
196 }
197}
198
199#[cfg(target_os = "linux")]
200fn launcher_with_binary(
201 binary: &std::path::Path,
202 env: &[(OsString, OsString)],
203 cwd: Option<&std::path::Path>,
204) -> Option<ScopeLauncher> {
205 probe(binary, env, cwd).then(|| ScopeLauncher {
206 systemd_run: binary.to_owned(),
207 })
208}
209
210pub fn launcher_for_env(env: Option<&[(String, String)]>) -> Option<ScopeLauncher> {
212 let env = env
213 .map(|e| e.iter().map(|(k, v)| (k.into(), v.into())).collect())
214 .unwrap_or_else(inherited_env);
215 launcher_for_effective_env(&env, None)
216}
217
218pub fn resolve_program(
221 program: OsString,
222 env: &[(OsString, OsString)],
223 cwd: Option<&std::path::Path>,
224) -> std::io::Result<OsString> {
225 #[cfg(unix)]
226 {
227 use std::os::unix::fs::PermissionsExt;
228 let base = cwd
229 .map(PathBuf::from)
230 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
231 let base = if base.is_absolute() {
232 base
233 } else {
234 std::env::current_dir().unwrap_or_default().join(base)
235 };
236 if std::path::Path::new(&program).components().count() > 1 {
237 return Ok(base.join(program).into_os_string());
238 }
239 let path = env
240 .iter()
241 .find(|(k, _)| k == "PATH")
242 .map(|(_, v)| v.clone())
243 .unwrap_or_else(|| "/bin:/usr/bin".into());
244 for dir in std::env::split_paths(&path) {
245 let candidate = base.join(dir).join(&program);
246 if candidate
247 .metadata()
248 .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
249 {
250 return Ok(candidate.into_os_string());
251 }
252 }
253 }
254 Err(std::io::Error::new(
255 std::io::ErrorKind::NotFound,
256 format!("program not found in child PATH: {program:?}"),
257 ))
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn wrap_puts_the_command_after_systemd_run_options() {
266 let l = ScopeLauncher {
267 systemd_run: PathBuf::from("/usr/bin/systemd-run"),
268 };
269 let (program, args) = l.wrap("synaps tool: bash", "bash", ["-c".into(), "echo hi".into()]);
270 assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
271 let args: Vec<String> = args
272 .iter()
273 .map(|a| a.to_string_lossy().into_owned())
274 .collect();
275 assert_eq!(
276 args,
277 [
278 "--user",
279 "--scope",
280 "--quiet",
281 "--collect",
282 "--description=synaps tool: bash",
283 "--",
284 "bash",
285 "-c",
286 "echo hi",
287 ]
288 );
289 }
290
291 #[test]
292 fn ordinary_test_build_does_not_probe_host_manager() {
293 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() == Ok("1") {
294 return;
295 }
296 let env = vec![(
297 "DBUS_SESSION_BUS_ADDRESS".into(),
298 "fake:must-not-probe".into(),
299 )];
300 assert!(launcher_for_effective_env(&env, None).is_none());
301 }
302
303 #[test]
304 fn env_variable_overrides_config_and_unknown_values_fall_back_to_config() {
305 assert!(enabled_from(None, true));
306 assert!(!enabled_from(None, false));
307 for off in ["0", "off", "OFF", " false ", "no"] {
308 assert!(!enabled_from(Some(off), true), "{off:?}");
309 }
310 for on in ["1", "on", "true", "yes", "auto"] {
311 assert!(enabled_from(Some(on), false), "{on:?}");
312 }
313 assert!(enabled_from(Some("maybe"), true));
314 assert!(!enabled_from(Some("maybe"), false));
315 }
316
317 #[cfg(target_os = "linux")]
320 fn fake_script(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
321 use std::os::unix::fs::PermissionsExt;
322 let path = dir.join(name);
323 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
324 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
325 path
326 }
327
328 #[cfg(target_os = "linux")]
329 #[test]
330 fn fresh_probe_uses_exact_env_cwd_and_observes_availability_changes() {
331 let dir = tempfile::tempdir().unwrap();
332 let binary = fake_script(
333 dir.path(),
334 "systemd-run",
335 r#"
336[ "$DBUS_SESSION_BUS_ADDRESS" = fake:good ] || exit 1
337[ "$TERM" = user-term ] || exit 2
338[ "$SYNAPS_DAEMON" = 1 ] || exit 3
339[ "$SYNAPS_DAEMON_READY_FD" = 987 ] || exit 4
340[ "${HOME+x}" != x ] || exit 5
341[ "$PWD" = "$EXPECTED_CWD" ] || exit 6
342while [ "$1" != -- ]; do shift; done
343shift
344[ "$1" = /bin/true ] || exit 7
345exit 0
346"#,
347 );
348 let mut env = vec![];
349 for (k, v) in [
350 ("DBUS_SESSION_BUS_ADDRESS", "fake:good"),
351 ("TERM", "user-term"),
352 ("SYNAPS_DAEMON", "1"),
353 ("SYNAPS_DAEMON_READY_FD", "987"),
354 ("PATH", "/nonexistent"),
355 ] {
356 set_env(&mut env, k, v);
357 }
358 set_env(&mut env, "EXPECTED_CWD", dir.path().as_os_str());
359 assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_some());
360 set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:stale");
361 assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_none());
362 set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:good");
363 assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_some());
364 fake_script(dir.path(), "systemd-run", "exit 1");
366 assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_none());
367 env.retain(|(k, _)| k != "DBUS_SESSION_BUS_ADDRESS");
368 assert!(!probe(&binary, &env, Some(dir.path())));
369 }
370
371 #[cfg(target_os = "linux")]
372 #[test]
373 fn post_probe_failure_never_replays_and_exec_preserves_pid_and_stdio() {
374 use std::io::Write;
375 use std::process::{Command, Stdio};
376 let dir = tempfile::tempdir().unwrap();
377 let command = fake_script(
378 dir.path(),
379 "command",
380 "printf 'command=%s\\n' \"$$\"; read line; printf '%s\\n' \"$line\"; exit 23",
381 );
382 let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
383 for started in [false, true] {
384 let binary = fake_script(
385 dir.path(),
386 "systemd-run",
387 &format!(
388 r#"
389while [ "$1" != -- ]; do shift; done
390shift
391[ "$1" = /bin/true ] && exit 0
392printf 'wrapper=%s\n' "$$"
393{}
394"#,
395 if started { "exec \"$@\"" } else { "exit 42" }
396 ),
397 );
398 let launcher = launcher_with_binary(&binary, &env, Some(dir.path())).unwrap();
399 let (program, args) = launcher.wrap("test", command.as_os_str(), []);
400 let mut child = Command::new(program)
401 .args(args)
402 .env_clear()
403 .envs(env.clone())
404 .current_dir(dir.path())
405 .stdin(Stdio::piped())
406 .stdout(Stdio::piped())
407 .spawn()
408 .unwrap();
409 let pid = child.id();
410 if started {
411 child.stdin.take().unwrap().write_all(b"input\n").unwrap();
412 }
413 let output = child.wait_with_output().unwrap();
414 let text = String::from_utf8(output.stdout).unwrap();
415 assert_eq!(
416 text,
417 if started {
418 format!("wrapper={pid}\ncommand={pid}\ninput\n")
419 } else {
420 format!("wrapper={pid}\n")
421 }
422 );
423 assert_eq!(output.status.code(), Some(if started { 23 } else { 42 }));
424 }
425 }
426
427 #[cfg(unix)]
428 #[test]
429 fn missing_child_path_program_is_an_error_not_launcher_path_fallback() {
430 let dir = tempfile::tempdir().unwrap();
431 let env = vec![("PATH".into(), dir.path().as_os_str().to_owned())];
432 assert!(resolve_program("bash".into(), &env, Some(dir.path())).is_err());
433 }
434
435 #[test]
436 fn host_policy_is_not_selected_from_the_child_environment() {
437 let source = include_str!("process_scope.rs");
438 let selection = source
439 .split("pub fn launcher_for_effective_env")
440 .nth(1)
441 .unwrap()
442 .split("/// Compatibility convenience")
443 .next()
444 .unwrap();
445 assert!(selection.contains("std::env::var(\"SYNAPS_PROCESS_SCOPES\")"));
446 assert!(!selection.contains("env.iter()"));
447 assert!(!enabled_from(Some("off"), true));
448 }
449
450 #[test]
451 fn production_callers_snapshot_overrides_and_do_not_retry_wrapped_launches() {
452 let pty = include_str!("tools/shell/pty.rs");
453 let spawn = pty
454 .split("pub(crate) fn prepare_scope")
455 .nth(1)
456 .unwrap()
457 .split("// 4.")
458 .next()
459 .unwrap();
460 assert!(
461 spawn.find("set_env(&mut effective_env, \"TERM\"").unwrap()
462 < spawn.find("for (k, v) in env").unwrap()
463 );
464 assert!(
465 spawn.find("for (k, v) in env").unwrap()
466 < spawn.find("launcher_for_effective_env").unwrap()
467 );
468 assert!(spawn.contains("cmd.env_clear();"));
469 assert_eq!(spawn.matches(".spawn_command(cmd)").count(), 1);
470 let daemon = include_str!("daemon/mod.rs");
471 let spawn = daemon.split("pub fn spawn_detached(").nth(1).unwrap();
472 assert!(
473 spawn.find("launcher_for_effective_env").unwrap() < spawn.find("libc::pipe(").unwrap()
474 );
475 assert!(
476 spawn.find("libc::pipe(").unwrap()
477 < spawn
478 .find("set_env(&mut effective_env, READY_FD_ENV")
479 .unwrap()
480 );
481 assert!(spawn.find("child.kill()").unwrap() < spawn.find("take(64 * 1024)").unwrap());
482 assert!(spawn.contains("cmd.env_clear().envs(effective_env)"));
483 let bash = include_str!("tools/bash.rs");
484 let spawn = bash
485 .split("pub(crate) async fn run_shell_command")
486 .nth(1)
487 .unwrap()
488 .split("#[cfg(test)]")
489 .next()
490 .unwrap();
491 assert_eq!(spawn.matches("cmd.spawn()").count(), 1);
492 assert!(spawn.contains("_ = cancel.cancelled()"));
493 }
494}