Skip to main content

agent_engine/
process_scope.rs

1//! Run child processes in their own systemd scope (Linux).
2//!
3//! systemd-oomd, under memory pressure, kills a whole cgroup at once, choosing
4//! the one using the most swap. Without this, everything Synaps starts lives
5//! in the cgroup of whatever started Synaps — usually a terminal's scope. A
6//! single runaway tool command (S348: a 270-file test run from the bash tool)
7//! then makes that whole scope the biggest, and systemd-oomd kills it: the
8//! command, the daemon, every session it hosts, and the terminal.
9//!
10//! A scope gives each child a separate cgroup, not a memory cap. An ancestor
11//! cgroup OOM or global kernel OOM can still kill the daemon and other scopes.
12//! Windows isolation is not implemented.
13//!
14//! `systemd-run --user --scope` registers a transient scope for its own pid
15//! and then execs the command in place: same pid, process group, session,
16//! environment, inherited fds and stdio. Group cleanup covers descendants that
17//! remain in that group. Each launch first performs a bounded fresh probe.
18//!
19//! Command arguments are passed verbatim: where systemd-run supports it
20//! (systemd >= 254, from a cached `--version`), `--expand-environment=no`
21//! stops it from substituting `${NAME}` in tool scripts before exec.
22//!
23//! Used only when it is available (Linux, a reachable systemd user manager)
24//! and not turned off (`process_scopes = off` in config, or
25//! `SYNAPS_PROCESS_SCOPES=off`). Otherwise commands start exactly as before.
26//!
27//! Slices: by default systemd puts each scope where the user manager puts
28//! transient units (usually `app.slice`, next to the desktop's apps). The
29//! config keys `tool_scope_slice` (bash tool commands and PTY shells) and
30//! `daemon_scope_slice` (the daemon's own scope) pass `--slice=<name>`, so
31//! an operator can cap agent tool work on its own slice. Synaps sets no
32//! limits; the operator sets them on the slice. The probe uses the same
33//! `--slice`: when it fails (no reachable user manager, or a slice it
34//! refuses) the command starts as it does without scopes, with one WARN per
35//! slice name. A slice that doesn't exist is not refused: systemd creates it
36//! on the fly, without limits. So, once per slice name and on a background
37//! thread, Synaps asks the manager for the slice's limits and WARNs if none
38//! is set (a typo, or a unit file not yet `daemon-reload`ed).
39
40use std::ffi::OsString;
41use std::path::PathBuf;
42#[cfg(target_os = "linux")]
43use std::sync::OnceLock;
44
45/// Which of Synaps's scopes a launch makes. Each kind has its own slice key.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum ScopeKind {
48    /// A tool command: a bash tool command or a PTY shell (`tool_scope_slice`).
49    Tool,
50    /// The daemon itself (`daemon_scope_slice`).
51    Daemon,
52}
53
54impl ScopeKind {
55    /// The config key naming this kind's slice.
56    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
64/// The configured slice for `kind`, if any. The config parser has already
65/// refused invalid names (they leave the key unset).
66fn 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/// Turns `program args…` into `systemd-run --user --scope … -- program args…`.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct ScopeLauncher {
76    systemd_run: PathBuf,
77    /// Pass `--expand-environment=no`. Only set when the binary is known to
78    /// accept it (systemd >= 254); an unknown option would fail every launch.
79    no_expand: bool,
80    /// `--slice=<name>`, from the kind's config key; `None` adds nothing.
81    slice: Option<String>,
82}
83
84/// First systemd release whose systemd-run knows `--expand-environment=`.
85///
86/// systemd NEWS: v254 added the switch and, for `--scope` only, left
87/// expansion off by default (with a warning); v258 turned it on for scopes
88/// too, so systemd-run itself rewrites `${NAME}`/`$NAME` in the command
89/// arguments before exec. Releases before 254 never expanded scope commands
90/// (a scope has no ExecStart for the manager to expand), so omitting the flag
91/// there keeps arguments verbatim.
92#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // read by the Linux probe; tested everywhere
93const EXPAND_ENVIRONMENT_SINCE: u32 = 254;
94
95/// systemd-run options shared by real launches and the probe, so the probe
96/// also proves the exact option set (the slice included) is accepted.
97fn 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        // Garbage-collect the unit even if the command fails, so failed
103        // commands do not accumulate as failed units.
104        "--collect".into(),
105    ];
106    if no_expand {
107        // Arguments are data: tool scripts contain literal `${...}` (shell
108        // parameter expansion, JS template literals) that systemd-run would
109        // otherwise substitute from its own environment.
110        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    /// The program to spawn and its full argument list. `description` names
121    /// the scope in `systemctl --user list-units --type=scope`.
122    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/// Major version from `systemd-run --version` ("systemd 259 (259.5-1)").
137#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // read by the Linux probe; tested everywhere
138fn 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/// Whether to pass `--expand-environment=no`; `None` if the version could not
145/// be determined, in which case scopes are not used at all: the flag may be
146/// required (>= 258) yet cannot be known to be accepted.
147#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // read by the Linux probe; tested everywhere
148fn no_expand_for_version(version: Option<u32>) -> Option<bool> {
149    version.map(|v| v >= EXPAND_ENVIRONMENT_SINCE)
150}
151
152/// `true` unless turned off. The environment variable wins over config so it
153/// can be flipped for one process without editing the config file.
154fn 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
162/// Snapshot inherited environment before applying child-specific overrides.
163pub 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    // Discovery alone is cached. Never cache manager availability or consult
175    // a tool-controlled PATH to choose the isolation launcher.
176    ["/usr/bin/systemd-run", "/bin/systemd-run"]
177        .into_iter()
178        .map(PathBuf::from)
179        .find(|p| p.is_file())
180}
181
182/// Whether `env` can reach a user manager at all (`XDG_RUNTIME_DIR` or
183/// `DBUS_SESSION_BUS_ADDRESS` set and non-empty).
184#[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/// Start only a throwaway scope, with the exact child environment and cwd and
191/// the same options real launches use (`slice` included). Bounded to three
192/// seconds.
193#[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/// `systemd-run --version`, bounded like the probe. Touches no manager.
216#[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/// Run a short helper command in its own session with null stdin/stderr.
230/// Returns its status and (if piped) stdout, or `None` on spawn failure or
231/// after three seconds, when the whole group is killed and reaped off-path.
232#[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    // Helper descendants must not survive a timeout. No real payload runs here.
238    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                // Output is tiny (a version line), well under a pipe buffer.
256                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                // Signal before any reap; the unreaped leader pins this ID.
265                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                // SIGKILL cannot bound kernel D-state. Reap eventually off the
274                // launch path rather than blocking the caller indefinitely.
275                std::thread::spawn(move || {
276                    let _ = child.wait();
277                });
278                return None;
279            }
280        }
281    }
282}
283
284/// Test seam, unit tests only: the `systemd-run` named by
285/// `SYNAPS_TEST_FAKE_SYSTEMD_RUN` in the launch's own environment. Tests
286/// that set it run only their own synthetic script; a test without it in
287/// its env is unaffected, so parallel tests don't see each other's fake.
288///
289/// There is no non-test version: outside `cfg(test)` this function and the
290/// variable's name don't exist, and the call site is `cfg(test)` too, so a
291/// release build cannot consult the seam at all.
292#[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
299/// Fresh, bounded availability check. Synchronous callers (PTY and detached
300/// daemon spawn) may wait up to three seconds. Async callers must offload it.
301/// Probe failure selects direct launch. After success, wrapper failure is an
302/// error, NEVER a reason to replay the command: availability can race, and the
303/// wrapped command may already have executed. This is not a launch handshake.
304/// `kind` picks the slice key (`tool_scope_slice` / `daemon_scope_slice`),
305/// read from the config this call loads.
306pub fn launcher_for_effective_env(
307    env: &[(OsString, OsString)],
308    cwd: Option<&std::path::Path>,
309    kind: ScopeKind,
310) -> Option<ScopeLauncher> {
311    // Unit tests only (always `None` otherwise): a synthetic systemd-run
312    // named in this launch's own env, so the callers' argv can be tested.
313    #[cfg(test)]
314    let fake = test_fake_systemd_run(env);
315    #[cfg(not(test))]
316    let fake: Option<PathBuf> = None;
317    // Test-support builds must not reach a developer's user manager merely
318    // because ordinary bash/PTY tests inherit a desktop environment. Synthetic
319    // launcher tests use a fake systemd-run; real smoke is opt-in.
320    #[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    // Operator policy belongs to the host, not the tool's rebuilt env. A
325    // child cannot enable scopes behind the host's explicit opt-out.
326    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            // The fake answers no `--version`: it launches without
338            // `--expand-environment=no`, as a pre-254 systemd-run would.
339            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        // A property of the binary, not of manager availability: cache only a
344        // determined answer. Unknown means no scope rather than a guess.
345        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    // Without a user manager in reach a slice changes nothing: no WARN then.
384    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/// The WARN for a failed probe with a slice set. The probe also fails when
393/// the user manager can't be reached (the bus env is set but stale, the
394/// manager is gone or slow), so it must not blame the slice alone.
395#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // logged by the Linux launcher; tested everywhere
396fn 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/// `true` the first time `name` is seen in `seen`.
403#[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/// `true` the first time `slice` fails its probe in this process, so a
414/// refused slice logs one WARN, not one per command.
415#[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/// `true` the first time a probe with `slice` succeeds in this process: its
422/// limits are looked up once per name, never per command.
423#[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
429/// Compatibility convenience for inherited or fully rebuilt environments,
430/// for a tool launch.
431pub 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
438/// The slice properties that cap what runs in it. If none is set, the slice
439/// caps nothing: most likely a typo (systemd makes a missing slice on the
440/// fly) or a unit file the manager hasn't loaded yet.
441const SLICE_LIMIT_PROPERTIES: &str = "MemoryHigh,MemoryMax,MemorySwapMax,CPUWeight,CPUQuotaPerSecUSec,TasksMax";
442
443/// From `systemctl --user show -p <SLICE_LIMIT_PROPERTIES> -p FragmentPath`:
444/// `Some(has_unit_file)` when the output lists the limit properties and every
445/// one is at its "no limit" value, `None` when one is set or the output is
446/// not what was asked for (then nothing is reported). systemd prints an
447/// unset value as `infinity`, `[not set]`, empty, or (older releases)
448/// `u64::MAX`.
449#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // read by the Linux check; tested everywhere
450fn 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/// The WARN for a slice with no limits.
470#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // logged by the Linux check; tested everywhere
471fn 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/// Ask the user manager `env` reaches whether `slice` sets any limit, with
483/// the `systemctl` next to the `systemd-run` in use (never a PATH lookup).
484/// `Some(has_unit_file)` if it sets none; `None` if it sets one or the
485/// question fails. Bounded like the probe.
486#[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/// Off the launch path: a detached thread looks the slice up and WARNs if it
505/// caps nothing. Best effort: a short-lived process may exit first, and any
506/// failure stays silent (the probe already decided the launch).
507#[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
521/// Resolve before wrapping: systemd-run must not substitute its own executable
522/// search rules for the child's PATH/cwd. Missing programs remain spawn errors.
523pub 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        // An old systemd-run: rejects the unknown option like the real one.
649        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        // Had support been misjudged, the probe fails: direct launch, never a
662        // wrapped launch with an option the binary rejects.
663        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    /// Real systemd-run and user manager; skipped where either is missing.
671    /// A literal `${x}` must reach the program unchanged even though `x` is
672    /// set in the child environment (systemd >= 258 expands it by default).
673    #[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    // These tests execute only synthetic scripts in private tempdirs, never a
747    // manager or a host systemd-run. No production marker-file protocol.
748    #[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        // Same discovered path, changed availability: no cached positive.
794        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        // Unset: the probe's options are today's, byte for byte.
974        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        // Set: the probe carries the launch's --slice.
980        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        // A manager that refuses the slice fails the probe: no launcher, so
997        // the command starts directly, as with scopes unavailable.
998        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    /// End to end through the selection: each kind's scope goes in its own
1018    /// configured slice; neither key set, no `--slice`; `process_scopes =
1019    /// off` still means no scope.
1020    #[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    /// The test seam is compiled into unit tests only: its one definition and
1051    /// its one call are each gated on `cfg(test)`, so a non-test build has
1052    /// neither (a stray ungated definition would also be dead code there,
1053    /// which clippy `-D warnings` rejects). Whitespace and line breaks don't
1054    /// matter to this check.
1055    #[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        // Built from pieces so this test's own text doesn't match.
1062        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        // Outside the cfg(test) modules (`tests`, `test_support`) the
1074        // variable is named in the seam alone.
1075        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        // systemd 262 on a slice nobody defined (made on the fly).
1091        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        // Older releases print an unset value as u64::MAX, or nothing.
1097        let old = missing.replace("[not set]", "18446744073709551615").replace("TasksMax=infinity", "TasksMax=");
1098        assert_eq!(slice_without_limits(&old), Some(false));
1099        // Any one limit set: nothing to report.
1100        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        // Output that doesn't answer the question (an error, a property
1111        // missing): nothing to report rather than a false alarm.
1112        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        // The gates are separate: a name checked for limits can still warn
1126        // once for a failed probe, and the other way round.
1127        assert!(first_refusal("limits-once-test-a.slice"));
1128    }
1129
1130    /// End to end with a fake `systemd-run` and a fake `systemctl` beside it:
1131    /// a successful probe with a slice asks the same manager (same env) for
1132    /// that slice's limits, once per name; without a slice nothing is asked.
1133    #[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        // Unset: no lookup.
1152        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        // Give a (wrong) second lookup time to show up before counting.
1162        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        // The query itself, synchronously: defaults and no unit file.
1168        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/// Shared by the callers' tests (bash, PTY, daemon): a synthetic
1176/// systemd-run, never a real manager.
1177#[cfg(all(test, target_os = "linux"))]
1178pub(crate) mod test_support {
1179    use std::path::{Path, PathBuf};
1180
1181    /// A fake `systemd-run` in `dir`. The probe (`… -- /bin/true`) writes its
1182    /// arguments to `dir/probe.args` and succeeds; a launch writes them to
1183    /// `dir/launch.args` and execs the command after `--`.
1184    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    /// The arguments the fake recorded (`probe.args` / `launch.args`).
1201    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    /// What a launch through the fake needs in its env: a user-bus address
1210    /// (the probe's precondition) and the seam variable naming the fake.
1211    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}