Skip to main content

wyvern/extensions/
preexec.rs

1//! Preexec subprocess spawn, PATH requires-check, and stdout capture.
2
3use std::ffi::OsString;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio};
7use std::time::{Duration, Instant};
8
9use super::{ExtensionError, StdoutCapture, TemplateErrorKind};
10
11/// Why a preexec subprocess failed.
12///
13/// `Timeout` is classified from the existing sync poll — it does not add async
14/// timeout infrastructure (sprint g.2 non-closure).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum PreexecFailureKind {
17    /// The helper binary could not be spawned (`ErrorKind::NotFound`).
18    SpawnNotFound {
19        /// Expanded `preexec.cmd`.
20        cmd: String,
21    },
22    /// The helper ran and exited nonzero.
23    NonZeroExit {
24        /// Process exit code, or `1` when killed by signal.
25        code: i32,
26        /// Last 4 KiB of child stderr.
27        stderr_tail: String,
28    },
29    /// The helper exceeded `WYVERN_PREEXEC_TIMEOUT_SECS` (sync poll).
30    Timeout {
31        /// Expanded `preexec.cmd`.
32        cmd: String,
33        /// Timeout that elapsed, in seconds.
34        timeout_secs: u64,
35    },
36}
37
38fn preexec_error(
39    kind: Option<PreexecFailureKind>,
40    message: impl Into<String>,
41    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
42) -> ExtensionError {
43    ExtensionError::Preexec {
44        kind,
45        message: message.into(),
46        source,
47    }
48}
49
50fn spawn_error(cmd: &str, err: std::io::Error) -> ExtensionError {
51    let kind = match err.kind() {
52        std::io::ErrorKind::NotFound => Some(PreexecFailureKind::SpawnNotFound {
53            cmd: cmd.to_string(),
54        }),
55        _ => None,
56    };
57    preexec_error(
58        kind,
59        format!("failed to spawn '{cmd}': {err}"),
60        Some(Box::new(err)),
61    )
62}
63
64fn nonzero_error(cmd: &str, status: &ExitStatus, stderr: String) -> ExtensionError {
65    let code = status.code().unwrap_or(1);
66    preexec_error(
67        Some(PreexecFailureKind::NonZeroExit {
68            code,
69            stderr_tail: stderr.clone(),
70        }),
71        preexec_fail_message(cmd, &status.to_string(), &stderr),
72        None,
73    )
74}
75
76/// Default preexec timeout in seconds. Override with `WYVERN_PREEXEC_TIMEOUT_SECS`.
77/// 30s covers compose/csv helpers without leaving a hung child unbounded.
78const DEFAULT_PREEXEC_TIMEOUT_SECS: u64 = 30;
79
80/// Max captured preexec stdout. 1 MiB is enough for markdown capture and
81/// prevents a runaway child from exhausting CLI memory.
82const MAX_PREEXEC_STDOUT_BYTES: usize = 1024 * 1024;
83
84/// Max preexec stderr included in [`ExtensionError::Preexec`] (PLAN-CRIT-009).
85const MAX_PREEXEC_STDERR_BYTES: usize = 4 * 1024;
86
87/// Poll interval while waiting for a preexec child. Short enough that typical
88/// helpers appear instantaneous; long enough to avoid a hot loop.
89const PREEXEC_WAIT_POLL: Duration = Duration::from_millis(20);
90
91/// Parse `WYVERN_PREEXEC_TIMEOUT_SECS`. Values below 1 second are rejected.
92fn parse_preexec_timeout_secs(raw: Option<&str>) -> Result<u64, ExtensionError> {
93    match raw {
94        None => Ok(DEFAULT_PREEXEC_TIMEOUT_SECS),
95        Some(v) => {
96            let secs: u64 = v.parse().map_err(|_| {
97                preexec_error(
98                    None,
99                    format!("WYVERN_PREEXEC_TIMEOUT_SECS={v} is not a positive integer"),
100                    None,
101                )
102            })?;
103            if secs < 1 {
104                return Err(preexec_error(
105                    None,
106                    "WYVERN_PREEXEC_TIMEOUT_SECS must be at least 1",
107                    None,
108                ));
109            }
110            Ok(secs)
111        }
112    }
113}
114
115fn preexec_timeout() -> Result<Duration, ExtensionError> {
116    parse_preexec_timeout_secs(std::env::var("WYVERN_PREEXEC_TIMEOUT_SECS").ok().as_deref())
117        .map(Duration::from_secs)
118}
119
120/// Probe used at match time for `preexec.requires`.
121pub trait RequiresProbe {
122    /// Returns whether `name` can be executed via `PATH`.
123    fn binary_on_path(&self, name: &str) -> bool;
124}
125
126/// Default probe that searches `PATH` (and Windows `PATHEXT`).
127#[derive(Debug, Clone, Copy, Default)]
128pub struct PathRequiresProbe;
129
130impl RequiresProbe for PathRequiresProbe {
131    fn binary_on_path(&self, name: &str) -> bool {
132        binary_on_path(name)
133    }
134}
135
136/// Return whether `name` resolves on `PATH`.
137#[must_use]
138pub fn binary_on_path(name: &str) -> bool {
139    if name.is_empty() {
140        return false;
141    }
142    let as_path = Path::new(name);
143    if as_path.is_absolute() {
144        return as_path.is_file();
145    }
146    let Some(paths) = std::env::var_os("PATH") else {
147        return false;
148    };
149    for dir in std::env::split_paths(&paths) {
150        if candidate_exists(&dir.join(name)) {
151            return true;
152        }
153        #[cfg(windows)]
154        {
155            for ext in ["exe", "cmd", "bat", "com"] {
156                if candidate_exists(&dir.join(format!("{name}.{ext}"))) {
157                    return true;
158                }
159            }
160        }
161    }
162    false
163}
164
165fn candidate_exists(path: &Path) -> bool {
166    path.is_file()
167}
168
169/// Subprocess request shared by extension preexec and workflow hooks.
170#[derive(Debug)]
171pub struct ScriptRequest {
172    /// Program name or absolute path.
173    pub program: OsString,
174    /// Arguments after the program.
175    pub args: Vec<OsString>,
176    /// Optional working directory.
177    pub cwd: Option<PathBuf>,
178    /// Extra environment variables (inherited env plus these keys).
179    pub extra_env: Vec<(OsString, OsString)>,
180    /// Optional stdin bytes. `None` uses `/dev/null`.
181    pub stdin: Option<Vec<u8>>,
182    /// When true, capture stdout (capped) instead of discarding it.
183    pub capture_stdout: bool,
184    /// Kill the child after this duration.
185    pub timeout: Duration,
186    /// When true on Unix, spawn in a new process group and kill the group
187    /// on timeout so descendants are reaped (workflow scripts).
188    pub process_group: bool,
189}
190
191/// Successful wait outcome for [`run_script`].
192#[derive(Debug)]
193pub struct ScriptOutput {
194    /// Captured stdout when requested.
195    pub stdout: Option<String>,
196    /// Last 4 KiB of child stderr.
197    pub stderr_tail: String,
198    /// Child exit status.
199    pub status: ExitStatus,
200}
201
202/// Failure from [`run_script`] (spawn, timeout, wait, or stdout capture).
203#[derive(Debug)]
204pub enum ScriptError {
205    /// Program was not found on PATH or as an absolute file.
206    SpawnNotFound {
207        /// Program token.
208        cmd: String,
209        /// Underlying I/O error.
210        source: std::io::Error,
211    },
212    /// Spawn failed for a reason other than not-found.
213    Spawn {
214        /// Program token.
215        cmd: String,
216        /// Underlying I/O error.
217        source: std::io::Error,
218    },
219    /// Child exceeded `timeout`.
220    Timeout {
221        /// Program token.
222        cmd: String,
223        /// Timeout in seconds.
224        timeout_secs: u64,
225        /// Stderr collected before kill.
226        stderr_tail: String,
227    },
228    /// `wait` failed after spawn.
229    Wait {
230        /// Program token.
231        cmd: String,
232        /// Underlying I/O error.
233        source: std::io::Error,
234    },
235    /// Stdout could not be read or was not UTF-8 / exceeded the cap.
236    Stdout {
237        /// Program token.
238        cmd: String,
239        /// Human-readable cause.
240        cause: String,
241    },
242    /// Helper thread could not be started.
243    Thread {
244        /// Human-readable cause.
245        message: String,
246    },
247}
248
249impl std::fmt::Display for ScriptError {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            Self::SpawnNotFound { cmd, source } => {
253                write!(f, "failed to spawn '{cmd}': {source}")
254            }
255            Self::Spawn { cmd, source } => write!(f, "failed to spawn '{cmd}': {source}"),
256            Self::Timeout {
257                cmd, timeout_secs, ..
258            } => write!(f, "{cmd} timed out after {timeout_secs}s"),
259            Self::Wait { cmd, source } => write!(f, "{cmd} wait failed: {source}"),
260            Self::Stdout { cmd, cause } => write!(f, "{cmd} stdout: {cause}"),
261            Self::Thread { message } => write!(f, "{message}"),
262        }
263    }
264}
265
266impl std::error::Error for ScriptError {
267    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
268        match self {
269            Self::SpawnNotFound { source, .. }
270            | Self::Spawn { source, .. }
271            | Self::Wait { source, .. } => Some(source),
272            _ => None,
273        }
274    }
275}
276
277/// Spawn a subprocess with timeout, optional stdin, and stderr tail.
278///
279/// Workflow hooks and extension preexec share this helper so `workflow/` does
280/// not grow a second `Command::new` stack (ADR-0023).
281///
282/// # Errors
283///
284/// Returns [`ScriptError`] when the process cannot be spawned, times out,
285/// exceeds the stdout cap, or wait/IO fails.
286pub fn run_script(request: &ScriptRequest) -> Result<ScriptOutput, ScriptError> {
287    let cmd = request.program.to_string_lossy().into_owned();
288    let mut child_cmd = Command::new(&request.program);
289    child_cmd.args(&request.args);
290    if let Some(cwd) = &request.cwd {
291        child_cmd.current_dir(cwd);
292    }
293    for (key, value) in &request.extra_env {
294        child_cmd.env(key, value);
295    }
296    child_cmd.stderr(Stdio::piped());
297    if request.capture_stdout {
298        child_cmd.stdout(Stdio::piped());
299    } else {
300        child_cmd.stdout(Stdio::null());
301    }
302    if request.stdin.is_some() {
303        child_cmd.stdin(Stdio::piped());
304    } else {
305        child_cmd.stdin(Stdio::null());
306    }
307    #[cfg(unix)]
308    if request.process_group {
309        use std::os::unix::process::CommandExt;
310        child_cmd.process_group(0);
311    }
312
313    let mut child = child_cmd.spawn().map_err(|err| {
314        if err.kind() == std::io::ErrorKind::NotFound {
315            ScriptError::SpawnNotFound {
316                cmd: cmd.clone(),
317                source: err,
318            }
319        } else {
320            ScriptError::Spawn {
321                cmd: cmd.clone(),
322                source: err,
323            }
324        }
325    })?;
326
327    if let Some(data) = &request.stdin {
328        if let Some(mut stdin) = child.stdin.take() {
329            if let Err(err) = stdin.write_all(data) {
330                terminate_child(&mut child, request.process_group);
331                return Err(ScriptError::Spawn { cmd, source: err });
332            }
333        }
334    }
335
336    let stderr_reader = spawn_stderr_reader(&mut child).map_err(|err| ScriptError::Thread {
337        message: err.to_string(),
338    })?;
339    let deadline = Instant::now() + request.timeout;
340
341    if request.capture_stdout {
342        let stdout = child.stdout.take().ok_or_else(|| ScriptError::Stdout {
343            cmd: cmd.clone(),
344            cause: "failed to capture stdout".into(),
345        })?;
346        let cmd_owned = cmd.clone();
347        let (tx, rx) = std::sync::mpsc::channel();
348        let reader = std::thread::Builder::new()
349            .name("script-stdout".into())
350            .spawn(move || {
351                let _ = tx.send(read_capped_stdout(&cmd_owned, stdout));
352            })
353            .map_err(|err| ScriptError::Thread {
354                message: format!("thread spawn failed: {err}"),
355            })?;
356
357        match rx.recv_timeout(request.timeout) {
358            Ok(Ok(raw)) => {
359                let grace_deadline = Instant::now() + Duration::from_millis(500);
360                let status = match wait_until(
361                    &mut child,
362                    &cmd,
363                    grace_deadline,
364                    request.timeout,
365                    request.process_group,
366                ) {
367                    Ok(status) => status,
368                    Err(err) => {
369                        let _ = reader.join();
370                        let stderr = join_stderr(stderr_reader);
371                        return Err(map_wait_error(err, stderr));
372                    }
373                };
374                let _ = reader.join();
375                let stderr_tail = join_stderr(stderr_reader);
376                let stdout = String::from_utf8(raw).map_err(|err| ScriptError::Stdout {
377                    cmd: cmd.clone(),
378                    cause: format!("not valid UTF-8: {err}"),
379                })?;
380                Ok(ScriptOutput {
381                    stdout: Some(stdout),
382                    stderr_tail,
383                    status,
384                })
385            }
386            Ok(Err(err)) => {
387                reap_killed(&mut child, reader, request.process_group);
388                let _ = join_stderr(stderr_reader);
389                Err(ScriptError::Stdout {
390                    cmd,
391                    cause: err.to_string(),
392                })
393            }
394            Err(_) => {
395                reap_killed(&mut child, reader, request.process_group);
396                let stderr_tail = join_stderr(stderr_reader);
397                Err(ScriptError::Timeout {
398                    cmd,
399                    timeout_secs: request.timeout.as_secs(),
400                    stderr_tail,
401                })
402            }
403        }
404    } else {
405        let status = match wait_until(
406            &mut child,
407            &cmd,
408            deadline,
409            request.timeout,
410            request.process_group,
411        ) {
412            Ok(status) => status,
413            Err(err) => {
414                let stderr = join_stderr(stderr_reader);
415                return Err(map_wait_error(err, stderr));
416            }
417        };
418        let stderr_tail = join_stderr(stderr_reader);
419        Ok(ScriptOutput {
420            stdout: None,
421            stderr_tail,
422            status,
423        })
424    }
425}
426
427fn map_wait_error(err: ExtensionError, stderr_tail: String) -> ScriptError {
428    match err {
429        ExtensionError::Preexec {
430            kind: Some(PreexecFailureKind::Timeout { cmd, timeout_secs }),
431            ..
432        } => ScriptError::Timeout {
433            cmd,
434            timeout_secs,
435            stderr_tail,
436        },
437        ExtensionError::Preexec {
438            message, source, ..
439        } => {
440            if let Some(source) =
441                source.and_then(|s| s.downcast::<std::io::Error>().ok().map(|b| *b))
442            {
443                ScriptError::Wait {
444                    cmd: message,
445                    source,
446                }
447            } else {
448                ScriptError::Wait {
449                    cmd: message,
450                    source: std::io::Error::other("wait failed"),
451                }
452            }
453        }
454        other => ScriptError::Wait {
455            cmd: other.to_string(),
456            source: std::io::Error::other(other.to_string()),
457        },
458    }
459}
460
461/// Runs the extension preexec command. On timeout the child is killed so a
462/// piped stdout reader cannot keep buffering after the CLI has moved on.
463/// See `WYVERN_PREEXEC_TIMEOUT_SECS`.
464///
465/// # Errors
466///
467/// Returns [`ExtensionError::Preexec`] when the process cannot be spawned,
468/// times out, exceeds the stdout cap, or exits non-zero.
469pub fn run_preexec(
470    cmd: &str,
471    args: &[String],
472    stdout_mode: Option<StdoutCapture>,
473) -> Result<Option<String>, ExtensionError> {
474    match stdout_mode {
475        None => run_without_capture(cmd, args).map(|()| None),
476        Some(StdoutCapture::Markdown) => run_capture_stdout(cmd, args).map(Some),
477    }
478}
479
480fn run_without_capture(cmd: &str, args: &[String]) -> Result<(), ExtensionError> {
481    let timeout = preexec_timeout()?;
482    let mut child = Command::new(cmd)
483        .args(args)
484        .stdin(Stdio::null())
485        .stderr(Stdio::piped())
486        .stdout(Stdio::null())
487        .spawn()
488        .map_err(|err| spawn_error(cmd, err))?;
489    let stderr_reader = spawn_stderr_reader(&mut child)?;
490    let deadline = Instant::now() + timeout;
491    let status = match wait_until(&mut child, cmd, deadline, timeout, false) {
492        Ok(status) => status,
493        Err(err) => {
494            let _ = join_stderr(stderr_reader);
495            return Err(err);
496        }
497    };
498    let stderr = join_stderr(stderr_reader);
499    if status.success() {
500        Ok(())
501    } else {
502        Err(nonzero_error(cmd, &status, stderr))
503    }
504}
505
506fn run_capture_stdout(cmd: &str, args: &[String]) -> Result<String, ExtensionError> {
507    let timeout = preexec_timeout()?;
508    let mut child = Command::new(cmd)
509        .args(args)
510        .stdin(Stdio::null())
511        .stderr(Stdio::piped())
512        .stdout(Stdio::piped())
513        .spawn()
514        .map_err(|err| spawn_error(cmd, err))?;
515    let stdout = child
516        .stdout
517        .take()
518        .ok_or_else(|| preexec_error(None, format!("failed to capture '{cmd}' stdout"), None))?;
519    let stderr_reader = spawn_stderr_reader(&mut child)?;
520    let cmd_owned = cmd.to_string();
521    let (tx, rx) = std::sync::mpsc::channel();
522    let reader = std::thread::Builder::new()
523        .name("preexec-stdout".into())
524        .spawn(move || {
525            let _ = tx.send(read_capped_stdout(&cmd_owned, stdout));
526        })
527        .map_err(|err| {
528            preexec_error(
529                None,
530                format!("thread spawn failed: {err}"),
531                Some(Box::new(err)),
532            )
533        })?;
534
535    match rx.recv_timeout(timeout) {
536        Ok(Ok(raw)) => {
537            // Child closed stdout and should exit promptly; do not reuse the
538            // pre-spawn deadline, which may already be nearly exhausted.
539            let grace_deadline = Instant::now() + Duration::from_millis(500);
540            let status = wait_until(&mut child, cmd, grace_deadline, timeout, false)?;
541            let _ = reader.join();
542            let stderr = join_stderr(stderr_reader);
543            if !status.success() {
544                return Err(nonzero_error(cmd, &status, stderr));
545            }
546            String::from_utf8(raw).map_err(|err| {
547                preexec_error(
548                    None,
549                    format!("{cmd} stdout is not valid UTF-8: {err}"),
550                    Some(Box::new(err)),
551                )
552            })
553        }
554        Ok(Err(err)) => {
555            reap_killed(&mut child, reader, false);
556            let _ = join_stderr(stderr_reader);
557            Err(err)
558        }
559        Err(_) => {
560            reap_killed(&mut child, reader, false);
561            let stderr = join_stderr(stderr_reader);
562            Err(preexec_error(
563                Some(PreexecFailureKind::Timeout {
564                    cmd: cmd.to_string(),
565                    timeout_secs: timeout.as_secs(),
566                }),
567                preexec_fail_message(
568                    cmd,
569                    &format!("timed out after {}s", timeout.as_secs()),
570                    &stderr,
571                ),
572                None,
573            ))
574        }
575    }
576}
577
578/// Read stdout with a hard byte cap so a runaway child cannot fill memory.
579fn read_capped_stdout(cmd: &str, stdout: ChildStdout) -> Result<Vec<u8>, ExtensionError> {
580    let mut buf = Vec::new();
581    let mut reader = stdout.take(MAX_PREEXEC_STDOUT_BYTES as u64 + 1);
582    reader.read_to_end(&mut buf).map_err(|err| {
583        preexec_error(
584            None,
585            format!("failed to read stdout: {err}"),
586            Some(Box::new(err)),
587        )
588    })?;
589    if buf.len() > MAX_PREEXEC_STDOUT_BYTES {
590        return Err(preexec_error(
591            None,
592            format!("{cmd} stdout exceeded {MAX_PREEXEC_STDOUT_BYTES} bytes"),
593            None,
594        ));
595    }
596    Ok(buf)
597}
598
599fn spawn_stderr_reader(
600    child: &mut Child,
601) -> Result<std::thread::JoinHandle<String>, ExtensionError> {
602    let stderr = child.stderr.take();
603    std::thread::Builder::new()
604        .name("preexec-stderr".into())
605        .spawn(move || {
606            let Some(stderr) = stderr else {
607                return String::new();
608            };
609            read_stderr_tail(stderr)
610        })
611        .map_err(|err| {
612            preexec_error(
613                None,
614                format!("thread spawn failed: {err}"),
615                Some(Box::new(err)),
616            )
617        })
618}
619
620/// Keep the last [`MAX_PREEXEC_STDERR_BYTES`] of child stderr (a tail, not a head).
621fn read_stderr_tail(mut reader: impl Read) -> String {
622    let mut tail = Vec::with_capacity(MAX_PREEXEC_STDERR_BYTES);
623    let mut chunk = [0_u8; 1024];
624    loop {
625        match reader.read(&mut chunk) {
626            Ok(0) => break,
627            Ok(n) => append_tail(&mut tail, &chunk[..n], MAX_PREEXEC_STDERR_BYTES),
628            Err(_) => break,
629        }
630    }
631    String::from_utf8_lossy(&tail).trim().to_string()
632}
633
634fn append_tail(tail: &mut Vec<u8>, data: &[u8], cap: usize) {
635    if data.len() >= cap {
636        tail.clear();
637        tail.extend_from_slice(&data[data.len() - cap..]);
638        return;
639    }
640    let combined = tail.len() + data.len();
641    if combined > cap {
642        tail.drain(..combined - cap);
643    }
644    tail.extend_from_slice(data);
645}
646
647fn join_stderr(reader: std::thread::JoinHandle<String>) -> String {
648    reader.join().unwrap_or_default()
649}
650
651fn preexec_fail_message(cmd: &str, status: &str, stderr: &str) -> String {
652    if stderr.is_empty() {
653        format!("'{cmd}' exited with {status}")
654    } else {
655        format!("'{cmd}' exited with {status}: {stderr}")
656    }
657}
658
659fn wait_until(
660    child: &mut Child,
661    cmd: &str,
662    deadline: Instant,
663    timeout: Duration,
664    process_group: bool,
665) -> Result<ExitStatus, ExtensionError> {
666    loop {
667        match child.try_wait() {
668            Ok(Some(status)) => return Ok(status),
669            Ok(None) => {
670                if Instant::now() >= deadline {
671                    terminate_child(child, process_group);
672                    return Err(preexec_error(
673                        Some(PreexecFailureKind::Timeout {
674                            cmd: cmd.to_string(),
675                            timeout_secs: timeout.as_secs(),
676                        }),
677                        format!("{cmd} timed out after {}s", timeout.as_secs()),
678                        None,
679                    ));
680                }
681                std::thread::sleep(PREEXEC_WAIT_POLL);
682            }
683            Err(err) => {
684                return Err(preexec_error(
685                    None,
686                    format!("{cmd} wait failed: {err}"),
687                    Some(Box::new(err)),
688                ));
689            }
690        }
691    }
692}
693
694fn reap_killed(child: &mut Child, reader: std::thread::JoinHandle<()>, process_group: bool) {
695    terminate_child(child, process_group);
696    let _ = reader.join();
697}
698
699fn terminate_child(child: &mut Child, process_group: bool) {
700    #[cfg(unix)]
701    {
702        if process_group {
703            kill_process_group(child);
704            let _ = child.wait();
705            return;
706        }
707    }
708    #[cfg(not(unix))]
709    let _ = process_group;
710    let _ = child.kill();
711    let _ = child.wait();
712}
713
714/// Send `SIGKILL` to the child's process group so descendants are reaped.
715///
716/// `child` must have been spawned with [`std::os::unix::process::CommandExt::process_group`]`(0)`,
717/// so its process-group id equals its pid. `kill(-pid, SIGKILL)` then targets
718/// that group only.
719#[cfg(unix)]
720fn kill_process_group(child: &Child) {
721    let pid = child.id() as i32;
722    if pid <= 1 {
723        return;
724    }
725    // SAFETY: child is the process-group leader created at spawn. Negative
726    // pid to `kill(2)` targets that group only.
727    let _ = unsafe { libc::kill(-pid, libc::SIGKILL) };
728}
729
730/// Lexicographically first `*.html` basename under `{tmpdir}/pages/`.
731///
732/// # Errors
733///
734/// Returns [`ExtensionError::Template`] when the directory is missing or empty.
735pub fn first_rendered_html(tmpdir: &Path) -> Result<String, ExtensionError> {
736    let pages = tmpdir.join("pages");
737    let mut names: Vec<String> = std::fs::read_dir(&pages)
738        .map_err(|err| {
739            ExtensionError::template(
740                TemplateErrorKind::Unavailable,
741                format!(
742                    "{{rendered_basename}} requires {{tmpdir}}/pages ({}): {err}",
743                    pages.display()
744                ),
745            )
746        })?
747        .filter_map(|entry| {
748            let entry = entry.ok()?;
749            let name = entry.file_name().into_string().ok()?;
750            name.to_ascii_lowercase().ends_with(".html").then_some(name)
751        })
752        .collect();
753    names.sort();
754    names.into_iter().next().ok_or_else(|| {
755        ExtensionError::template(
756            TemplateErrorKind::Unavailable,
757            format!(
758                "{{rendered_basename}} found no *.html under {}",
759                pages.display()
760            ),
761        )
762    })
763}
764
765/// Create a secure temp directory for `{tmpdir}`.
766///
767/// # Errors
768///
769/// Returns [`ExtensionError::Io`] when a temp dir cannot be created.
770pub fn create_tmpdir() -> Result<tempfile::TempDir, ExtensionError> {
771    tempfile::TempDir::new().map_err(|err| ExtensionError::Io {
772        message: format!("could not create extension temp dir: {err}"),
773        source: Some(Box::new(err)),
774    })
775}
776
777/// Path of an owned temp dir as a [`PathBuf`].
778#[must_use]
779pub fn tmpdir_path(dir: &tempfile::TempDir) -> PathBuf {
780    dir.path().to_path_buf()
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn path_probe_finds_common_binaries() {
789        // `false` / `echo` exist on Unix CI; skip assertion if PATH is empty.
790        if std::env::var_os("PATH").is_none() {
791            return;
792        }
793        let _ = binary_on_path("false") || binary_on_path("echo") || binary_on_path("sh");
794    }
795
796    #[cfg(unix)]
797    #[test]
798    fn preexec_nonzero_is_error() {
799        let err = run_preexec("false", &[], None).expect_err("false");
800        assert!(matches!(
801            err,
802            ExtensionError::Preexec {
803                kind: Some(PreexecFailureKind::NonZeroExit { .. }),
804                ..
805            }
806        ));
807    }
808
809    #[test]
810    fn spawn_error_maps_not_found_vs_other() {
811        let not_found = spawn_error(
812            "missing-bin",
813            std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
814        );
815        assert!(
816            matches!(
817                not_found,
818                ExtensionError::Preexec {
819                    kind: Some(PreexecFailureKind::SpawnNotFound { ref cmd }),
820                    ..
821                } if cmd == "missing-bin"
822            ),
823            "{not_found:?}"
824        );
825        let denied = spawn_error(
826            "locked-bin",
827            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
828        );
829        assert!(
830            matches!(denied, ExtensionError::Preexec { kind: None, .. }),
831            "{denied:?}"
832        );
833    }
834
835    #[test]
836    fn preexec_missing_binary_is_spawn_not_found() {
837        let err = run_preexec("wyvern-g2-missing-bin-xyz", &[], None).expect_err("missing");
838        assert!(
839            matches!(
840                err,
841                ExtensionError::Preexec {
842                    kind: Some(PreexecFailureKind::SpawnNotFound { ref cmd }),
843                    ..
844                } if cmd == "wyvern-g2-missing-bin-xyz"
845            ),
846            "{err:?}"
847        );
848    }
849
850    #[cfg(unix)]
851    #[test]
852    fn preexec_markdown_stdout_capture() {
853        let out =
854            run_preexec("printf", &["# hi".into()], Some(StdoutCapture::Markdown)).expect("printf");
855        assert_eq!(out.as_deref(), Some("# hi"));
856    }
857
858    #[cfg(unix)]
859    #[test]
860    fn preexec_stdout_cap_rejects_oversize() {
861        if !binary_on_path("dd") {
862            return; // dd not available on this platform
863        }
864        let err = run_preexec(
865            "dd",
866            &["if=/dev/zero".into(), "bs=1024".into(), "count=2048".into()],
867            Some(StdoutCapture::Markdown),
868        )
869        .expect_err("oversize stdout");
870        assert!(
871            matches!(err, ExtensionError::Preexec { ref message, .. } if message.contains("exceeded")),
872            "{err:?}"
873        );
874    }
875
876    #[test]
877    fn first_rendered_html_picks_lexicographic_first() {
878        let tmp = tempfile::tempdir().expect("tmp");
879        let pages = tmp.path().join("pages");
880        std::fs::create_dir_all(&pages).expect("mkdir");
881        std::fs::write(pages.join("foo.html"), "<p>x</p>").expect("write");
882        std::fs::write(pages.join("zzz.html"), "<p>z</p>").expect("write");
883        assert_eq!(first_rendered_html(tmp.path()).expect("html"), "foo.html");
884    }
885
886    #[test]
887    fn preexec_timeout_zero_is_rejected() {
888        let err = parse_preexec_timeout_secs(Some("0")).expect_err("zero");
889        assert!(
890            matches!(err, ExtensionError::Preexec { ref message, .. } if message.contains("at least 1")),
891            "{err}"
892        );
893        assert_eq!(
894            parse_preexec_timeout_secs(None).expect("default"),
895            DEFAULT_PREEXEC_TIMEOUT_SECS
896        );
897    }
898
899    #[cfg(unix)]
900    #[test]
901    fn preexec_stderr_appears_in_error() {
902        let err = run_preexec(
903            "sh",
904            &["-c".into(), "echo known-stderr-line >&2; exit 1".into()],
905            None,
906        )
907        .expect_err("nonzero");
908        let text = format!("{err}");
909        assert!(
910            text.contains("known-stderr-line"),
911            "preexec error must include stderr snippet: {text}"
912        );
913    }
914}