Skip to main content

wyvern/extensions/
preexec.rs

1//! Preexec subprocess spawn, PATH requires-check, and stdout capture.
2
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio};
6use std::time::{Duration, Instant};
7
8use super::{ExtensionError, StdoutCapture, TemplateErrorKind};
9
10/// Why a preexec subprocess failed.
11///
12/// `Timeout` is classified from the existing sync poll — it does not add async
13/// timeout infrastructure (sprint g.2 non-closure).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PreexecFailureKind {
16    /// The helper binary could not be spawned (`ErrorKind::NotFound`).
17    SpawnNotFound {
18        /// Expanded `preexec.cmd`.
19        cmd: String,
20    },
21    /// The helper ran and exited nonzero.
22    NonZeroExit {
23        /// Process exit code, or `1` when killed by signal.
24        code: i32,
25        /// Last 4 KiB of child stderr.
26        stderr_tail: String,
27    },
28    /// The helper exceeded `WYVERN_PREEXEC_TIMEOUT_SECS` (sync poll).
29    Timeout {
30        /// Expanded `preexec.cmd`.
31        cmd: String,
32        /// Timeout that elapsed, in seconds.
33        timeout_secs: u64,
34    },
35}
36
37fn preexec_error(
38    kind: Option<PreexecFailureKind>,
39    message: impl Into<String>,
40    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
41) -> ExtensionError {
42    ExtensionError::Preexec {
43        kind,
44        message: message.into(),
45        source,
46    }
47}
48
49fn spawn_error(cmd: &str, err: std::io::Error) -> ExtensionError {
50    let kind = match err.kind() {
51        std::io::ErrorKind::NotFound => Some(PreexecFailureKind::SpawnNotFound {
52            cmd: cmd.to_string(),
53        }),
54        _ => None,
55    };
56    preexec_error(
57        kind,
58        format!("failed to spawn '{cmd}': {err}"),
59        Some(Box::new(err)),
60    )
61}
62
63fn nonzero_error(cmd: &str, status: &ExitStatus, stderr: String) -> ExtensionError {
64    let code = status.code().unwrap_or(1);
65    preexec_error(
66        Some(PreexecFailureKind::NonZeroExit {
67            code,
68            stderr_tail: stderr.clone(),
69        }),
70        preexec_fail_message(cmd, &status.to_string(), &stderr),
71        None,
72    )
73}
74
75/// Default preexec timeout in seconds. Override with `WYVERN_PREEXEC_TIMEOUT_SECS`.
76/// 30s covers compose/csv helpers without leaving a hung child unbounded.
77const DEFAULT_PREEXEC_TIMEOUT_SECS: u64 = 30;
78
79/// Max captured preexec stdout. 1 MiB is enough for markdown capture and
80/// prevents a runaway child from exhausting CLI memory.
81const MAX_PREEXEC_STDOUT_BYTES: usize = 1024 * 1024;
82
83/// Max preexec stderr included in [`ExtensionError::Preexec`] (PLAN-CRIT-009).
84const MAX_PREEXEC_STDERR_BYTES: usize = 4 * 1024;
85
86/// Poll interval while waiting for a preexec child. Short enough that typical
87/// helpers appear instantaneous; long enough to avoid a hot loop.
88const PREEXEC_WAIT_POLL: Duration = Duration::from_millis(20);
89
90/// Parse `WYVERN_PREEXEC_TIMEOUT_SECS`. Values below 1 second are rejected.
91fn parse_preexec_timeout_secs(raw: Option<&str>) -> Result<u64, ExtensionError> {
92    match raw {
93        None => Ok(DEFAULT_PREEXEC_TIMEOUT_SECS),
94        Some(v) => {
95            let secs: u64 = v.parse().map_err(|_| {
96                preexec_error(
97                    None,
98                    format!("WYVERN_PREEXEC_TIMEOUT_SECS={v} is not a positive integer"),
99                    None,
100                )
101            })?;
102            if secs < 1 {
103                return Err(preexec_error(
104                    None,
105                    "WYVERN_PREEXEC_TIMEOUT_SECS must be at least 1",
106                    None,
107                ));
108            }
109            Ok(secs)
110        }
111    }
112}
113
114fn preexec_timeout() -> Result<Duration, ExtensionError> {
115    parse_preexec_timeout_secs(std::env::var("WYVERN_PREEXEC_TIMEOUT_SECS").ok().as_deref())
116        .map(Duration::from_secs)
117}
118
119/// Probe used at match time for `preexec.requires`.
120pub trait RequiresProbe {
121    /// Returns whether `name` can be executed via `PATH`.
122    fn binary_on_path(&self, name: &str) -> bool;
123}
124
125/// Default probe that searches `PATH` (and Windows `PATHEXT`).
126#[derive(Debug, Clone, Copy, Default)]
127pub struct PathRequiresProbe;
128
129impl RequiresProbe for PathRequiresProbe {
130    fn binary_on_path(&self, name: &str) -> bool {
131        binary_on_path(name)
132    }
133}
134
135/// Return whether `name` resolves on `PATH`.
136#[must_use]
137pub fn binary_on_path(name: &str) -> bool {
138    if name.is_empty() {
139        return false;
140    }
141    let as_path = Path::new(name);
142    if as_path.is_absolute() {
143        return as_path.is_file();
144    }
145    let Some(paths) = std::env::var_os("PATH") else {
146        return false;
147    };
148    for dir in std::env::split_paths(&paths) {
149        if candidate_exists(&dir.join(name)) {
150            return true;
151        }
152        #[cfg(windows)]
153        {
154            for ext in ["exe", "cmd", "bat", "com"] {
155                if candidate_exists(&dir.join(format!("{name}.{ext}"))) {
156                    return true;
157                }
158            }
159        }
160    }
161    false
162}
163
164fn candidate_exists(path: &Path) -> bool {
165    path.is_file()
166}
167
168/// Runs the extension preexec command. On timeout the child is killed so a
169/// piped stdout reader cannot keep buffering after the CLI has moved on.
170/// See `WYVERN_PREEXEC_TIMEOUT_SECS`.
171///
172/// # Errors
173///
174/// Returns [`ExtensionError::Preexec`] when the process cannot be spawned,
175/// times out, exceeds the stdout cap, or exits non-zero.
176pub fn run_preexec(
177    cmd: &str,
178    args: &[String],
179    stdout_mode: Option<StdoutCapture>,
180) -> Result<Option<String>, ExtensionError> {
181    match stdout_mode {
182        None => run_without_capture(cmd, args).map(|()| None),
183        Some(StdoutCapture::Markdown) => run_capture_stdout(cmd, args).map(Some),
184    }
185}
186
187fn run_without_capture(cmd: &str, args: &[String]) -> Result<(), ExtensionError> {
188    let timeout = preexec_timeout()?;
189    let mut child = Command::new(cmd)
190        .args(args)
191        .stdin(Stdio::null())
192        .stderr(Stdio::piped())
193        .stdout(Stdio::null())
194        .spawn()
195        .map_err(|err| spawn_error(cmd, err))?;
196    let stderr_reader = spawn_stderr_reader(&mut child)?;
197    let deadline = Instant::now() + timeout;
198    let status = match wait_until(&mut child, cmd, deadline, timeout) {
199        Ok(status) => status,
200        Err(err) => {
201            let _ = join_stderr(stderr_reader);
202            return Err(err);
203        }
204    };
205    let stderr = join_stderr(stderr_reader);
206    if status.success() {
207        Ok(())
208    } else {
209        Err(nonzero_error(cmd, &status, stderr))
210    }
211}
212
213fn run_capture_stdout(cmd: &str, args: &[String]) -> Result<String, ExtensionError> {
214    let timeout = preexec_timeout()?;
215    let mut child = Command::new(cmd)
216        .args(args)
217        .stdin(Stdio::null())
218        .stderr(Stdio::piped())
219        .stdout(Stdio::piped())
220        .spawn()
221        .map_err(|err| spawn_error(cmd, err))?;
222    let stdout = child
223        .stdout
224        .take()
225        .ok_or_else(|| preexec_error(None, format!("failed to capture '{cmd}' stdout"), None))?;
226    let stderr_reader = spawn_stderr_reader(&mut child)?;
227    let cmd_owned = cmd.to_string();
228    let (tx, rx) = std::sync::mpsc::channel();
229    let reader = std::thread::Builder::new()
230        .name("preexec-stdout".into())
231        .spawn(move || {
232            let _ = tx.send(read_capped_stdout(&cmd_owned, stdout));
233        })
234        .map_err(|err| {
235            preexec_error(
236                None,
237                format!("thread spawn failed: {err}"),
238                Some(Box::new(err)),
239            )
240        })?;
241
242    match rx.recv_timeout(timeout) {
243        Ok(Ok(raw)) => {
244            // Child closed stdout and should exit promptly; do not reuse the
245            // pre-spawn deadline, which may already be nearly exhausted.
246            let grace_deadline = Instant::now() + Duration::from_millis(500);
247            let status = wait_until(&mut child, cmd, grace_deadline, timeout)?;
248            let _ = reader.join();
249            let stderr = join_stderr(stderr_reader);
250            if !status.success() {
251                return Err(nonzero_error(cmd, &status, stderr));
252            }
253            String::from_utf8(raw).map_err(|err| {
254                preexec_error(
255                    None,
256                    format!("{cmd} stdout is not valid UTF-8: {err}"),
257                    Some(Box::new(err)),
258                )
259            })
260        }
261        Ok(Err(err)) => {
262            reap_killed(&mut child, reader);
263            let _ = join_stderr(stderr_reader);
264            Err(err)
265        }
266        Err(_) => {
267            reap_killed(&mut child, reader);
268            let stderr = join_stderr(stderr_reader);
269            Err(preexec_error(
270                Some(PreexecFailureKind::Timeout {
271                    cmd: cmd.to_string(),
272                    timeout_secs: timeout.as_secs(),
273                }),
274                preexec_fail_message(
275                    cmd,
276                    &format!("timed out after {}s", timeout.as_secs()),
277                    &stderr,
278                ),
279                None,
280            ))
281        }
282    }
283}
284
285/// Read stdout with a hard byte cap so a runaway child cannot fill memory.
286fn read_capped_stdout(cmd: &str, stdout: ChildStdout) -> Result<Vec<u8>, ExtensionError> {
287    let mut buf = Vec::new();
288    let mut reader = stdout.take(MAX_PREEXEC_STDOUT_BYTES as u64 + 1);
289    reader.read_to_end(&mut buf).map_err(|err| {
290        preexec_error(
291            None,
292            format!("failed to read stdout: {err}"),
293            Some(Box::new(err)),
294        )
295    })?;
296    if buf.len() > MAX_PREEXEC_STDOUT_BYTES {
297        return Err(preexec_error(
298            None,
299            format!("{cmd} stdout exceeded {MAX_PREEXEC_STDOUT_BYTES} bytes"),
300            None,
301        ));
302    }
303    Ok(buf)
304}
305
306fn spawn_stderr_reader(
307    child: &mut Child,
308) -> Result<std::thread::JoinHandle<String>, ExtensionError> {
309    let stderr = child.stderr.take();
310    std::thread::Builder::new()
311        .name("preexec-stderr".into())
312        .spawn(move || {
313            let Some(stderr) = stderr else {
314                return String::new();
315            };
316            read_stderr_tail(stderr)
317        })
318        .map_err(|err| {
319            preexec_error(
320                None,
321                format!("thread spawn failed: {err}"),
322                Some(Box::new(err)),
323            )
324        })
325}
326
327/// Keep the last [`MAX_PREEXEC_STDERR_BYTES`] of child stderr (a tail, not a head).
328fn read_stderr_tail(mut reader: impl Read) -> String {
329    let mut tail = Vec::with_capacity(MAX_PREEXEC_STDERR_BYTES);
330    let mut chunk = [0_u8; 1024];
331    loop {
332        match reader.read(&mut chunk) {
333            Ok(0) => break,
334            Ok(n) => append_tail(&mut tail, &chunk[..n], MAX_PREEXEC_STDERR_BYTES),
335            Err(_) => break,
336        }
337    }
338    String::from_utf8_lossy(&tail).trim().to_string()
339}
340
341fn append_tail(tail: &mut Vec<u8>, data: &[u8], cap: usize) {
342    if data.len() >= cap {
343        tail.clear();
344        tail.extend_from_slice(&data[data.len() - cap..]);
345        return;
346    }
347    let combined = tail.len() + data.len();
348    if combined > cap {
349        tail.drain(..combined - cap);
350    }
351    tail.extend_from_slice(data);
352}
353
354fn join_stderr(reader: std::thread::JoinHandle<String>) -> String {
355    reader.join().unwrap_or_default()
356}
357
358fn preexec_fail_message(cmd: &str, status: &str, stderr: &str) -> String {
359    if stderr.is_empty() {
360        format!("'{cmd}' exited with {status}")
361    } else {
362        format!("'{cmd}' exited with {status}: {stderr}")
363    }
364}
365
366fn wait_until(
367    child: &mut Child,
368    cmd: &str,
369    deadline: Instant,
370    timeout: Duration,
371) -> Result<ExitStatus, ExtensionError> {
372    loop {
373        match child.try_wait() {
374            Ok(Some(status)) => return Ok(status),
375            Ok(None) => {
376                if Instant::now() >= deadline {
377                    let _ = child.kill();
378                    let _ = child.wait();
379                    return Err(preexec_error(
380                        Some(PreexecFailureKind::Timeout {
381                            cmd: cmd.to_string(),
382                            timeout_secs: timeout.as_secs(),
383                        }),
384                        format!("{cmd} timed out after {}s", timeout.as_secs()),
385                        None,
386                    ));
387                }
388                std::thread::sleep(PREEXEC_WAIT_POLL);
389            }
390            Err(err) => {
391                return Err(preexec_error(
392                    None,
393                    format!("{cmd} wait failed: {err}"),
394                    Some(Box::new(err)),
395                ));
396            }
397        }
398    }
399}
400
401fn reap_killed(child: &mut Child, reader: std::thread::JoinHandle<()>) {
402    let _ = child.kill();
403    let _ = child.wait();
404    let _ = reader.join();
405}
406
407/// Lexicographically first `*.html` basename under `{tmpdir}/pages/`.
408///
409/// # Errors
410///
411/// Returns [`ExtensionError::Template`] when the directory is missing or empty.
412pub fn first_rendered_html(tmpdir: &Path) -> Result<String, ExtensionError> {
413    let pages = tmpdir.join("pages");
414    let mut names: Vec<String> = std::fs::read_dir(&pages)
415        .map_err(|err| {
416            ExtensionError::template(
417                TemplateErrorKind::Unavailable,
418                format!(
419                    "{{rendered_basename}} requires {{tmpdir}}/pages ({}): {err}",
420                    pages.display()
421                ),
422            )
423        })?
424        .filter_map(|entry| {
425            let entry = entry.ok()?;
426            let name = entry.file_name().into_string().ok()?;
427            name.to_ascii_lowercase().ends_with(".html").then_some(name)
428        })
429        .collect();
430    names.sort();
431    names.into_iter().next().ok_or_else(|| {
432        ExtensionError::template(
433            TemplateErrorKind::Unavailable,
434            format!(
435                "{{rendered_basename}} found no *.html under {}",
436                pages.display()
437            ),
438        )
439    })
440}
441
442/// Create a secure temp directory for `{tmpdir}`.
443///
444/// # Errors
445///
446/// Returns [`ExtensionError::Io`] when a temp dir cannot be created.
447pub fn create_tmpdir() -> Result<tempfile::TempDir, ExtensionError> {
448    tempfile::TempDir::new().map_err(|err| ExtensionError::Io {
449        message: format!("could not create extension temp dir: {err}"),
450        source: Some(Box::new(err)),
451    })
452}
453
454/// Path of an owned temp dir as a [`PathBuf`].
455#[must_use]
456pub fn tmpdir_path(dir: &tempfile::TempDir) -> PathBuf {
457    dir.path().to_path_buf()
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn path_probe_finds_common_binaries() {
466        // `false` / `echo` exist on Unix CI; skip assertion if PATH is empty.
467        if std::env::var_os("PATH").is_none() {
468            return;
469        }
470        let _ = binary_on_path("false") || binary_on_path("echo") || binary_on_path("sh");
471    }
472
473    #[cfg(unix)]
474    #[test]
475    fn preexec_nonzero_is_error() {
476        let err = run_preexec("false", &[], None).expect_err("false");
477        assert!(matches!(
478            err,
479            ExtensionError::Preexec {
480                kind: Some(PreexecFailureKind::NonZeroExit { .. }),
481                ..
482            }
483        ));
484    }
485
486    #[test]
487    fn spawn_error_maps_not_found_vs_other() {
488        let not_found = spawn_error(
489            "missing-bin",
490            std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
491        );
492        assert!(
493            matches!(
494                not_found,
495                ExtensionError::Preexec {
496                    kind: Some(PreexecFailureKind::SpawnNotFound { ref cmd }),
497                    ..
498                } if cmd == "missing-bin"
499            ),
500            "{not_found:?}"
501        );
502        let denied = spawn_error(
503            "locked-bin",
504            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
505        );
506        assert!(
507            matches!(denied, ExtensionError::Preexec { kind: None, .. }),
508            "{denied:?}"
509        );
510    }
511
512    #[test]
513    fn preexec_missing_binary_is_spawn_not_found() {
514        let err = run_preexec("wyvern-g2-missing-bin-xyz", &[], None).expect_err("missing");
515        assert!(
516            matches!(
517                err,
518                ExtensionError::Preexec {
519                    kind: Some(PreexecFailureKind::SpawnNotFound { ref cmd }),
520                    ..
521                } if cmd == "wyvern-g2-missing-bin-xyz"
522            ),
523            "{err:?}"
524        );
525    }
526
527    #[cfg(unix)]
528    #[test]
529    fn preexec_markdown_stdout_capture() {
530        let out =
531            run_preexec("printf", &["# hi".into()], Some(StdoutCapture::Markdown)).expect("printf");
532        assert_eq!(out.as_deref(), Some("# hi"));
533    }
534
535    #[cfg(unix)]
536    #[test]
537    fn preexec_stdout_cap_rejects_oversize() {
538        if !binary_on_path("dd") {
539            return; // dd not available on this platform
540        }
541        let err = run_preexec(
542            "dd",
543            &["if=/dev/zero".into(), "bs=1024".into(), "count=2048".into()],
544            Some(StdoutCapture::Markdown),
545        )
546        .expect_err("oversize stdout");
547        assert!(
548            matches!(err, ExtensionError::Preexec { ref message, .. } if message.contains("exceeded")),
549            "{err:?}"
550        );
551    }
552
553    #[test]
554    fn first_rendered_html_picks_lexicographic_first() {
555        let tmp = tempfile::tempdir().expect("tmp");
556        let pages = tmp.path().join("pages");
557        std::fs::create_dir_all(&pages).expect("mkdir");
558        std::fs::write(pages.join("foo.html"), "<p>x</p>").expect("write");
559        std::fs::write(pages.join("zzz.html"), "<p>z</p>").expect("write");
560        assert_eq!(first_rendered_html(tmp.path()).expect("html"), "foo.html");
561    }
562
563    #[test]
564    fn preexec_timeout_zero_is_rejected() {
565        let err = parse_preexec_timeout_secs(Some("0")).expect_err("zero");
566        assert!(
567            matches!(err, ExtensionError::Preexec { ref message, .. } if message.contains("at least 1")),
568            "{err}"
569        );
570        assert_eq!(
571            parse_preexec_timeout_secs(None).expect("default"),
572            DEFAULT_PREEXEC_TIMEOUT_SECS
573        );
574    }
575
576    #[cfg(unix)]
577    #[test]
578    fn preexec_stderr_appears_in_error() {
579        let err = run_preexec(
580            "sh",
581            &["-c".into(), "echo known-stderr-line >&2; exit 1".into()],
582            None,
583        )
584        .expect_err("nonzero");
585        let text = format!("{err}");
586        assert!(
587            text.contains("known-stderr-line"),
588            "preexec error must include stderr snippet: {text}"
589        );
590    }
591}