Skip to main content

qli_ext/
dispatch.rs

1//! Extension dispatch with the Phase 1F guard sequence wrapped around the
2//! child spawn.
3//!
4//! The eight steps the plan specifies (banner → `requires_env` → confirm →
5//! secrets → audit-start → spawn → wait → audit-finish) are executed in
6//! order; each step gates the next. Failures before spawn surface as
7//! [`DispatchError`] without ever reaching the child.
8//!
9//! `Command::spawn` (not `exec`) is deliberate: the dispatcher must outlive
10//! the child to write the post-run audit entry, propagate exit codes, and —
11//! on SIGINT/SIGTERM mid-run — forward the signal to the child via the
12//! [`DispatchSignals`] handle the binary installs into its global signal
13//! handler.
14
15use std::collections::HashMap;
16use std::ffi::OsStr;
17use std::io;
18use std::path::PathBuf;
19use std::process::{Command, ExitStatus};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex};
22use std::time::Instant;
23
24use chrono::Utc;
25use thiserror::Error;
26
27use crate::audit::{self, AuditError, AuditEvent};
28use crate::discovery::{Extension, Group};
29use crate::guard::{self, ConfirmPrompt, GuardError};
30use crate::secrets::{ResolvedSecret, SecretsError, SecretsResolver};
31
32/// Tunables and dependencies the dispatcher needs from the binary.
33pub struct DispatchOptions<'a> {
34    /// Skip the confirm prompt (the user passed `--yes`).
35    pub assume_yes: bool,
36    /// Resolve every `secrets[*]` entry in the manifest up-front.
37    pub resolver: &'a dyn SecretsResolver,
38    /// How to ask the user when `confirm = true` and stdin is a TTY.
39    pub confirm: &'a dyn ConfirmPrompt,
40    /// Shared interrupt + child-PID slot the binary's signal handler updates.
41    pub signals: Arc<DispatchSignals>,
42    /// Fallback values for env vars referenced in `audit_log` (typically the
43    /// resolved XDG defaults so manifests can use `$XDG_STATE_HOME` even
44    /// when the user hasn't exported it).
45    pub audit_path_defaults: HashMap<String, String>,
46}
47
48impl std::fmt::Debug for DispatchOptions<'_> {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("DispatchOptions")
51            .field("assume_yes", &self.assume_yes)
52            .field("audit_path_defaults", &self.audit_path_defaults)
53            .finish_non_exhaustive()
54    }
55}
56
57/// Shared between the dispatcher and the binary's ctrlc handler.
58///
59/// The handler calls [`DispatchSignals::on_signal`] when SIGINT/SIGTERM
60/// fires; the dispatcher registers/clears the running child's PID and reads
61/// `was_interrupted()` after the child exits to decide between `Finish` and
62/// `Interrupted` audit events.
63#[derive(Debug, Default)]
64pub struct DispatchSignals {
65    interrupted: AtomicBool,
66    child_pid: Mutex<Option<u32>>,
67}
68
69impl DispatchSignals {
70    #[must_use]
71    pub fn new() -> Arc<Self> {
72        Arc::new(Self::default())
73    }
74
75    /// Called from the binary's signal handler. Marks the run as interrupted
76    /// and forwards SIGTERM to the child if one is registered. Forwarding is
77    /// a no-op on non-Unix targets (signal semantics differ on Windows; the
78    /// foundation plan stays Unix-first for now).
79    pub fn on_signal(&self) {
80        self.interrupted.store(true, Ordering::SeqCst);
81        if let Some(pid) = *self.lock_pid() {
82            forward_terminate(pid);
83        }
84    }
85
86    fn lock_pid(&self) -> std::sync::MutexGuard<'_, Option<u32>> {
87        self.child_pid.lock().expect("child_pid mutex poisoned")
88    }
89
90    fn set_child(&self, pid: u32) {
91        *self.lock_pid() = Some(pid);
92    }
93
94    fn clear_child(&self) {
95        *self.lock_pid() = None;
96    }
97
98    fn was_interrupted(&self) -> bool {
99        self.interrupted.load(Ordering::SeqCst)
100    }
101}
102
103/// Top-level error from [`run`]. Each variant maps to a specific guard step
104/// or post-spawn failure mode so callers can render targeted diagnostics.
105#[derive(Debug, Error)]
106pub enum DispatchError {
107    #[error(transparent)]
108    Guard(#[from] GuardError),
109    #[error(transparent)]
110    Secrets(#[from] SecretsError),
111    #[error(transparent)]
112    Audit(#[from] AuditError),
113    /// A resolved secret carries data that would crash `Command::env`. The
114    /// value itself is deliberately omitted from the message — only the
115    /// env-var name and a description of *why* it's bad.
116    #[error("resolved secret for env `{env}` is invalid: {reason}")]
117    SecretValueInvalid { env: String, reason: &'static str },
118    #[error("could not spawn `{path}`: {source}")]
119    Spawn {
120        path: PathBuf,
121        #[source]
122        source: io::Error,
123    },
124    #[error("error waiting on `{path}`: {source}")]
125    Wait {
126        path: PathBuf,
127        #[source]
128        source: io::Error,
129    },
130}
131
132/// Run an extension under the full guard sequence.
133///
134/// Returns the child's exit code (host conventions: signal exits → 128+sig
135/// on Unix). Pre-spawn failures return the corresponding [`DispatchError`]
136/// variant — the caller is responsible for mapping that to a process exit
137/// code.
138pub fn run<I, S>(
139    group: &Group,
140    extension: &Extension,
141    args: I,
142    opts: &DispatchOptions<'_>,
143) -> Result<i32, DispatchError>
144where
145    I: IntoIterator<Item = S>,
146    S: AsRef<OsStr>,
147{
148    let args: Vec<std::ffi::OsString> = args.into_iter().map(|s| s.as_ref().to_owned()).collect();
149
150    // 1. Banner.
151    guard::print_banner(&group.manifest);
152
153    // 2. requires_env.
154    guard::check_requires_env(&group.manifest)?;
155
156    // 3. Confirm (gated *before* secrets so we don't hit `op` for an abort).
157    guard::run_confirm(
158        &group.manifest,
159        &group.name,
160        &extension.name,
161        opts.assume_yes,
162        opts.confirm,
163    )?;
164
165    // 4. Resolve secrets up-front, fail closed.
166    let resolved = opts.resolver.resolve_all(&group.manifest.secrets)?;
167
168    // 5. Audit start.
169    let audit_path = group
170        .manifest
171        .audit_log
172        .as_deref()
173        .map(|s| audit::expand_path(s, &opts.audit_path_defaults))
174        .transpose()?;
175    if let Some(path) = &audit_path {
176        audit::append(
177            path,
178            &AuditEvent::Start {
179                timestamp: Utc::now(),
180                user: audit::current_user(),
181                group: group.name.clone(),
182                extension: extension.name.clone(),
183                args: args
184                    .iter()
185                    .map(|a| a.to_string_lossy().into_owned())
186                    .collect(),
187                env_var_names: resolved.iter().map(|r| r.env.clone()).collect(),
188            },
189        )?;
190    }
191
192    // 6 + 7. Spawn, register PID for signal forwarding, wait.
193    let started = Instant::now();
194    let mut command = Command::new(&extension.path);
195    command.args(&args);
196    apply_secret_env(&mut command, &resolved)?;
197    let mut child = command.spawn().map_err(|source| DispatchError::Spawn {
198        path: extension.path.clone(),
199        source,
200    })?;
201    opts.signals.set_child(child.id());
202    // The `Command` (and so the resolved values it stores) lives until end of
203    // function; `resolved` is one of two copies, not the only one. We do NOT
204    // promise zeroization here — secrets handling is a defense-in-depth task
205    // tracked separately.
206    drop(resolved);
207    let status = child.wait().map_err(|source| DispatchError::Wait {
208        path: extension.path.clone(),
209        source,
210    });
211    opts.signals.clear_child();
212    let status: ExitStatus = status?;
213    let duration_ms = started.elapsed().as_millis();
214    let code = exit_code(status);
215
216    // 8. Audit finish (or interrupted, if the signal handler tagged the run).
217    if let Some(path) = &audit_path {
218        let event = if opts.signals.was_interrupted() {
219            AuditEvent::Interrupted {
220                timestamp: Utc::now(),
221                group: group.name.clone(),
222                extension: extension.name.clone(),
223                signal: signal_label(status),
224                exit_code: code,
225                duration_ms,
226            }
227        } else {
228            AuditEvent::Finish {
229                timestamp: Utc::now(),
230                group: group.name.clone(),
231                extension: extension.name.clone(),
232                exit_code: code,
233                duration_ms,
234            }
235        };
236        // A failed finish/interrupted write is reported as a warning so the
237        // child's exit code still propagates — the child has already run; we
238        // don't want to fabricate an error from a logging glitch.
239        if let Err(err) = audit::append(path, &event) {
240            eprintln!("warning: failed to write audit-finish entry: {err}");
241        }
242    }
243
244    Ok(code)
245}
246
247fn apply_secret_env(
248    command: &mut Command,
249    resolved: &[ResolvedSecret],
250) -> Result<(), DispatchError> {
251    for secret in resolved {
252        // The manifest parser rejects bad env names; the env variant is for
253        // values returned from a resolver (provider data we didn't author).
254        // `Command::env` panics on a value containing NUL — fail with a typed
255        // error pointing at the offending env name instead.
256        if secret.value.contains('\0') {
257            return Err(DispatchError::SecretValueInvalid {
258                env: secret.env.clone(),
259                reason: "value contains NUL — the secret provider returned malformed data",
260            });
261        }
262        command.env(&secret.env, &secret.value);
263    }
264    Ok(())
265}
266
267#[cfg(unix)]
268fn exit_code(status: ExitStatus) -> i32 {
269    use std::os::unix::process::ExitStatusExt;
270    if let Some(code) = status.code() {
271        code
272    } else if let Some(sig) = status.signal() {
273        128 + sig
274    } else {
275        1
276    }
277}
278
279#[cfg(not(unix))]
280fn exit_code(status: ExitStatus) -> i32 {
281    status.code().unwrap_or(1)
282}
283
284#[cfg(unix)]
285fn signal_label(status: ExitStatus) -> String {
286    use std::os::unix::process::ExitStatusExt;
287    match status.signal() {
288        Some(2) => "SIGINT".into(),
289        Some(15) => "SIGTERM".into(),
290        Some(other) => format!("SIG{other}"),
291        None => "interrupted".into(),
292    }
293}
294
295#[cfg(not(unix))]
296fn signal_label(_status: ExitStatus) -> String {
297    "interrupted".into()
298}
299
300#[cfg(unix)]
301fn forward_terminate(pid: u32) {
302    // Pids are non-negative; on Unix `pid_t` is `i32` and the kernel will
303    // never hand back a u32 with the high bit set. `try_into` rejects the
304    // pathological case rather than silently wrapping.
305    let Ok(raw) = i32::try_from(pid) else {
306        return;
307    };
308    let target = nix::unistd::Pid::from_raw(raw);
309    // Best-effort: child may already have exited (ESRCH), or we may lack
310    // permission. Either way, the dispatcher's main thread will reach
311    // `child.wait()` shortly and tear things down.
312    let _ = nix::sys::signal::kill(target, nix::sys::signal::Signal::SIGTERM);
313}
314
315#[cfg(not(unix))]
316fn forward_terminate(_pid: u32) {
317    // Windows signal forwarding requires console-control routing; deferred
318    // until a Windows port is in scope.
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::manifest::Manifest;
325    use crate::secrets::TestResolver;
326
327    struct AlwaysYes;
328    impl ConfirmPrompt for AlwaysYes {
329        fn ask(&self, _message: &str) -> Result<bool, GuardError> {
330            Ok(true)
331        }
332    }
333
334    fn manifest(
335        confirm: bool,
336        env: &[(&str, &str)],
337        audit_log: Option<&str>,
338        secrets: Vec<crate::manifest::SecretSpec>,
339    ) -> Manifest {
340        Manifest {
341            schema_version: 1,
342            description: "test".into(),
343            banner: None,
344            requires_env: env
345                .iter()
346                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
347                .collect(),
348            confirm,
349            audit_log: audit_log.map(str::to_owned),
350            secrets,
351        }
352    }
353
354    fn group(manifest: Manifest) -> Group {
355        Group {
356            name: "dev".into(),
357            manifest_path: PathBuf::from("/dev/null/_manifest.toml"),
358            manifest,
359            extensions: std::collections::BTreeMap::new(),
360        }
361    }
362
363    fn extension(path: PathBuf) -> Extension {
364        Extension {
365            name: "hello".into(),
366            group: "dev".into(),
367            path,
368            origin: crate::discovery::ExtensionOrigin::Xdg,
369        }
370    }
371
372    fn opts<'a>(
373        resolver: &'a TestResolver,
374        confirm: &'a AlwaysYes,
375        signals: Arc<DispatchSignals>,
376    ) -> DispatchOptions<'a> {
377        DispatchOptions {
378            assume_yes: false,
379            resolver,
380            confirm,
381            signals,
382            audit_path_defaults: HashMap::new(),
383        }
384    }
385
386    #[test]
387    #[cfg(unix)]
388    fn happy_path_runs_extension_and_writes_audit() {
389        let tmp = tempfile::tempdir().unwrap();
390        let script = tmp.path().join("hello");
391        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
392        {
393            use std::os::unix::fs::PermissionsExt;
394            let mut perms = std::fs::metadata(&script).unwrap().permissions();
395            perms.set_mode(0o755);
396            std::fs::set_permissions(&script, perms).unwrap();
397        }
398        let audit_path = tmp.path().join("audit.log");
399        let g = group(manifest(false, &[], audit_path.to_str(), Vec::new()));
400        let e = extension(script);
401        let resolver = TestResolver::new();
402        let confirm = AlwaysYes;
403        let signals = DispatchSignals::new();
404        let o = opts(&resolver, &confirm, signals);
405        let code = run(&g, &e, std::iter::empty::<&str>(), &o).unwrap();
406        assert_eq!(code, 0);
407        let body = std::fs::read_to_string(&audit_path).unwrap();
408        let lines: Vec<&str> = body.lines().collect();
409        assert_eq!(lines.len(), 2, "got: {body}");
410        assert!(lines[0].contains("\"event\":\"start\""));
411        assert!(lines[1].contains("\"event\":\"finish\""));
412        assert!(lines[1].contains("\"exit_code\":0"));
413    }
414
415    #[test]
416    #[cfg(unix)]
417    #[serial_test::serial]
418    fn requires_env_blocks_spawn() {
419        // Unique env var name per test (defense in depth) plus
420        // `#[serial]` for hard isolation against any future env-mutating
421        // sibling test in this binary.
422        std::env::remove_var("QLI_DISPATCH_TEST_REQ");
423        let tmp = tempfile::tempdir().unwrap();
424        let script = tmp.path().join("hello");
425        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
426        let g = group(manifest(
427            false,
428            &[("QLI_DISPATCH_TEST_REQ", "yes")],
429            None,
430            Vec::new(),
431        ));
432        let e = extension(script);
433        let resolver = TestResolver::new();
434        let confirm = AlwaysYes;
435        let signals = DispatchSignals::new();
436        let o = opts(&resolver, &confirm, signals);
437        let err = run(&g, &e, std::iter::empty::<&str>(), &o).unwrap_err();
438        assert!(matches!(
439            err,
440            DispatchError::Guard(GuardError::EnvMissing { .. })
441        ));
442    }
443
444    #[test]
445    #[cfg(unix)]
446    fn secrets_propagate_to_child_env_but_not_audit() {
447        let tmp = tempfile::tempdir().unwrap();
448        let script = tmp.path().join("dump");
449        // Print the env var the manifest injects to a sentinel file.
450        let dump_path = tmp.path().join("child-env");
451        let body = format!(
452            "#!/bin/sh\nprintf '%s' \"$INJECTED\" > {}\nexit 0\n",
453            dump_path.display()
454        );
455        std::fs::write(&script, body).unwrap();
456        {
457            use std::os::unix::fs::PermissionsExt;
458            let mut perms = std::fs::metadata(&script).unwrap().permissions();
459            perms.set_mode(0o755);
460            std::fs::set_permissions(&script, perms).unwrap();
461        }
462        let audit_path = tmp.path().join("audit.log");
463        let secret = crate::manifest::SecretSpec {
464            env: "INJECTED".into(),
465            reference: "ref-x".into(),
466            provider: crate::manifest::SecretProvider::Env,
467        };
468        let g = group(manifest(false, &[], audit_path.to_str(), vec![secret]));
469        let e = extension(script);
470        let resolver = TestResolver::new().with("ref-x", "SECRET_SENTINEL_AAA");
471        let confirm = AlwaysYes;
472        let signals = DispatchSignals::new();
473        let o = opts(&resolver, &confirm, signals);
474        let code = run(&g, &e, std::iter::empty::<&str>(), &o).unwrap();
475        assert_eq!(code, 0);
476        let dumped = std::fs::read_to_string(&dump_path).unwrap();
477        assert_eq!(dumped, "SECRET_SENTINEL_AAA", "child should receive secret");
478        let audit_body = std::fs::read_to_string(&audit_path).unwrap();
479        assert!(
480            !audit_body.contains("SECRET_SENTINEL_AAA"),
481            "audit log must not contain secret value: {audit_body}",
482        );
483        assert!(
484            audit_body.contains("\"INJECTED\""),
485            "env var name must be recorded"
486        );
487    }
488
489    #[test]
490    #[cfg(unix)]
491    fn signal_forwarding_writes_interrupted_audit_and_exits_with_signal_code() {
492        let tmp = tempfile::tempdir().unwrap();
493        let script = tmp.path().join("sleeper");
494        // Long-running child. SIGTERM should reach it via the dispatcher's
495        // forward path; if forwarding breaks, this test will hang for ~60s
496        // before the runner's timeout fires, which makes the failure obvious.
497        std::fs::write(&script, "#!/bin/sh\nsleep 60\nexit 0\n").unwrap();
498        {
499            use std::os::unix::fs::PermissionsExt;
500            let mut perms = std::fs::metadata(&script).unwrap().permissions();
501            perms.set_mode(0o755);
502            std::fs::set_permissions(&script, perms).unwrap();
503        }
504        let audit_path = tmp.path().join("audit.log");
505        let g = group(manifest(false, &[], audit_path.to_str(), Vec::new()));
506        let e = extension(script);
507        let resolver = TestResolver::new();
508        let confirm = AlwaysYes;
509        let signals = DispatchSignals::new();
510        let trigger = Arc::clone(&signals);
511
512        let handle = std::thread::spawn(move || {
513            std::thread::sleep(std::time::Duration::from_millis(200));
514            // Fire what a real Ctrl+C / SIGTERM handler would do.
515            trigger.on_signal();
516        });
517
518        let o = opts(&resolver, &confirm, signals);
519        let code = run(&g, &e, std::iter::empty::<&str>(), &o).unwrap();
520        handle.join().unwrap();
521
522        // Child was killed by SIGTERM (signo 15) → exit_code = 128 + 15.
523        assert_eq!(code, 143, "expected SIGTERM exit code 143, got {code}");
524        let audit_body = std::fs::read_to_string(&audit_path).unwrap();
525        let lines: Vec<&str> = audit_body.lines().collect();
526        assert_eq!(lines.len(), 2, "expected start + interrupted: {audit_body}");
527        assert!(lines[0].contains("\"event\":\"start\""));
528        assert!(
529            lines[1].contains("\"event\":\"interrupted\""),
530            "expected interrupted event, got: {}",
531            lines[1],
532        );
533        assert!(lines[1].contains("\"signal\":\"SIGTERM\""));
534        assert!(lines[1].contains("\"exit_code\":143"));
535    }
536
537    #[test]
538    #[cfg(unix)]
539    fn nul_in_resolved_secret_value_is_rejected_before_spawn() {
540        let tmp = tempfile::tempdir().unwrap();
541        let script = tmp.path().join("hello");
542        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
543        {
544            use std::os::unix::fs::PermissionsExt;
545            let mut perms = std::fs::metadata(&script).unwrap().permissions();
546            perms.set_mode(0o755);
547            std::fs::set_permissions(&script, perms).unwrap();
548        }
549        let secret = crate::manifest::SecretSpec {
550            env: "TOKEN".into(),
551            reference: "ref-bad".into(),
552            provider: crate::manifest::SecretProvider::Env,
553        };
554        let g = group(manifest(false, &[], None, vec![secret]));
555        let e = extension(script);
556        // Resolver returns a value containing NUL — exec-time would panic.
557        let resolver = TestResolver::new().with("ref-bad", "good\0bad");
558        let confirm = AlwaysYes;
559        let signals = DispatchSignals::new();
560        let o = opts(&resolver, &confirm, signals);
561        let err = run(&g, &e, std::iter::empty::<&str>(), &o).unwrap_err();
562        match err {
563            DispatchError::SecretValueInvalid { env, .. } => assert_eq!(env, "TOKEN"),
564            other => panic!("expected SecretValueInvalid, got {other:?}"),
565        }
566    }
567
568    #[test]
569    #[cfg(unix)]
570    fn child_exit_code_propagates() {
571        let tmp = tempfile::tempdir().unwrap();
572        let script = tmp.path().join("explode");
573        std::fs::write(&script, "#!/bin/sh\nexit 7\n").unwrap();
574        {
575            use std::os::unix::fs::PermissionsExt;
576            let mut perms = std::fs::metadata(&script).unwrap().permissions();
577            perms.set_mode(0o755);
578            std::fs::set_permissions(&script, perms).unwrap();
579        }
580        let g = group(manifest(false, &[], None, Vec::new()));
581        let e = extension(script);
582        let resolver = TestResolver::new();
583        let confirm = AlwaysYes;
584        let signals = DispatchSignals::new();
585        let o = opts(&resolver, &confirm, signals);
586        let code = run(&g, &e, std::iter::empty::<&str>(), &o).unwrap();
587        assert_eq!(code, 7);
588    }
589}