1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum PreexecFailureKind {
17 SpawnNotFound {
19 cmd: String,
21 },
22 NonZeroExit {
24 code: i32,
26 stderr_tail: String,
28 },
29 Timeout {
31 cmd: String,
33 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
76const DEFAULT_PREEXEC_TIMEOUT_SECS: u64 = 30;
79
80const MAX_PREEXEC_STDOUT_BYTES: usize = 1024 * 1024;
83
84const MAX_PREEXEC_STDERR_BYTES: usize = 4 * 1024;
86
87const PREEXEC_WAIT_POLL: Duration = Duration::from_millis(20);
90
91fn 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
120pub trait RequiresProbe {
122 fn binary_on_path(&self, name: &str) -> bool;
124}
125
126#[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#[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#[derive(Debug)]
171pub struct ScriptRequest {
172 pub program: OsString,
174 pub args: Vec<OsString>,
176 pub cwd: Option<PathBuf>,
178 pub extra_env: Vec<(OsString, OsString)>,
180 pub stdin: Option<Vec<u8>>,
182 pub capture_stdout: bool,
184 pub timeout: Duration,
186 pub process_group: bool,
189}
190
191#[derive(Debug)]
193pub struct ScriptOutput {
194 pub stdout: Option<String>,
196 pub stderr_tail: String,
198 pub status: ExitStatus,
200}
201
202#[derive(Debug)]
204pub enum ScriptError {
205 SpawnNotFound {
207 cmd: String,
209 source: std::io::Error,
211 },
212 Spawn {
214 cmd: String,
216 source: std::io::Error,
218 },
219 Timeout {
221 cmd: String,
223 timeout_secs: u64,
225 stderr_tail: String,
227 },
228 Wait {
230 cmd: String,
232 source: std::io::Error,
234 },
235 Stdout {
237 cmd: String,
239 cause: String,
241 },
242 Thread {
244 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
277pub 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
461pub 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 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
578fn 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
620fn 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#[cfg(unix)]
720fn kill_process_group(child: &Child) {
721 let pid = child.id() as i32;
722 if pid <= 1 {
723 return;
724 }
725 let _ = unsafe { libc::kill(-pid, libc::SIGKILL) };
728}
729
730pub 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
765pub 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#[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 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; }
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}