1use 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
32pub struct DispatchOptions<'a> {
34 pub assume_yes: bool,
36 pub resolver: &'a dyn SecretsResolver,
38 pub confirm: &'a dyn ConfirmPrompt,
40 pub signals: Arc<DispatchSignals>,
42 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#[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 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#[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 #[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
132pub 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 guard::print_banner(&group.manifest);
152
153 guard::check_requires_env(&group.manifest)?;
155
156 guard::run_confirm(
158 &group.manifest,
159 &group.name,
160 &extension.name,
161 opts.assume_yes,
162 opts.confirm,
163 )?;
164
165 let resolved = opts.resolver.resolve_all(&group.manifest.secrets)?;
167
168 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 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 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 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 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 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 let Ok(raw) = i32::try_from(pid) else {
306 return;
307 };
308 let target = nix::unistd::Pid::from_raw(raw);
309 let _ = nix::sys::signal::kill(target, nix::sys::signal::Signal::SIGTERM);
313}
314
315#[cfg(not(unix))]
316fn forward_terminate(_pid: u32) {
317 }
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 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 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 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 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 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 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}