1use 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#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PreexecFailureKind {
16 SpawnNotFound {
18 cmd: String,
20 },
21 NonZeroExit {
23 code: i32,
25 stderr_tail: String,
27 },
28 Timeout {
30 cmd: String,
32 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
75const DEFAULT_PREEXEC_TIMEOUT_SECS: u64 = 30;
78
79const MAX_PREEXEC_STDOUT_BYTES: usize = 1024 * 1024;
82
83const MAX_PREEXEC_STDERR_BYTES: usize = 4 * 1024;
85
86const PREEXEC_WAIT_POLL: Duration = Duration::from_millis(20);
89
90fn 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
119pub trait RequiresProbe {
121 fn binary_on_path(&self, name: &str) -> bool;
123}
124
125#[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#[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
168pub 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 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
285fn 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
327fn 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
407pub 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
442pub 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#[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 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; }
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}