1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::{NewPluginInstall, PluginInstallRecord, RuntimeStore, data_dir};
11
12const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
17
18const HOOK_OUTPUT_CAP: usize = 64 * 1024;
22
23const HOOK_DENY_EXIT_CODE: i32 = 2;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PluginManifest {
29 pub name: String,
30 #[serde(default)]
31 pub version: Option<String>,
32 #[serde(default)]
33 pub description: Option<String>,
34 #[serde(default)]
35 pub skills: Vec<String>,
36 #[serde(default)]
37 pub agents: Vec<String>,
38 #[serde(default)]
39 pub hooks: Vec<String>,
40 #[serde(default)]
41 pub mcp: Vec<String>,
42 #[serde(default)]
49 pub capabilities: Vec<String>,
50 #[serde(default)]
51 pub prompts: Vec<String>,
52 #[serde(default)]
53 pub bin: Vec<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PluginCapabilityPreview {
61 pub name: String,
62 pub declared_capabilities: Vec<String>,
63 pub capabilities_toml: Option<toml::Value>,
64 pub hooks: Vec<String>,
65 pub mcp: Vec<String>,
66 pub bin: Vec<String>,
67}
68
69pub fn validate_plugin_manifest(manifest: &PluginManifest, root: &Path) -> Result<()> {
70 anyhow::ensure!(!manifest.name.trim().is_empty(), "plugin name is required");
71 ensure_relative_paths("skills", &manifest.skills, root)?;
72 ensure_relative_paths("agents", &manifest.agents, root)?;
73 ensure_relative_paths("hooks", &manifest.hooks, root)?;
74 ensure_relative_paths("mcp", &manifest.mcp, root)?;
75 ensure_relative_paths("prompts", &manifest.prompts, root)?;
76 ensure_relative_paths("bin", &manifest.bin, root)?;
77 Ok(())
78}
79
80pub fn install_plugin_from_path(path: &Path) -> Result<PluginInstallRecord> {
81 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
82 validate_plugin_manifest(&manifest, &root)?;
83 let manifest_json = serde_json::to_string_pretty(&manifest)?;
84 let store = RuntimeStore::open_default()?;
85 let record = store.plugins().install(NewPluginInstall {
86 id: Some(manifest.name.clone()),
87 name: manifest.name,
88 source: root.display().to_string(),
89 version: manifest.version,
90 enabled: false,
95 manifest_json,
96 })?;
97 write_plugin_lockfile()?;
98 Ok(record)
99}
100
101pub fn plugin_capability_preview(path: &Path) -> Result<PluginCapabilityPreview> {
102 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
103 validate_plugin_manifest(&manifest, &root)?;
104 let capabilities_path = root.join("capabilities.toml");
105 let capabilities_toml = if capabilities_path.exists() {
106 let raw = std::fs::read_to_string(&capabilities_path)
107 .with_context(|| format!("failed to read {}", capabilities_path.display()))?;
108 Some(toml::from_str(&raw)?)
109 } else {
110 None
111 };
112 Ok(PluginCapabilityPreview {
113 name: manifest.name,
114 declared_capabilities: manifest.capabilities,
115 capabilities_toml,
116 hooks: manifest.hooks,
117 mcp: manifest.mcp,
118 bin: manifest.bin,
119 })
120}
121
122pub fn write_plugin_lockfile() -> Result<PathBuf> {
123 let store = RuntimeStore::open_default()?;
124 let plugins = store.plugins().list()?;
125 let path = data_dir()?.join("plugins.lock.json");
126 if let Some(parent) = path.parent() {
127 std::fs::create_dir_all(parent)?;
128 }
129 crate::write_atomic(&path, &serde_json::to_vec_pretty(&plugins)?)?;
131 Ok(path)
132}
133
134#[derive(Debug, Clone, Default, PartialEq)]
138pub struct HookResponse {
139 pub plugin: String,
141 pub hook: String,
143 pub decision: HookDecision,
145 pub updated_input: Option<serde_json::Value>,
147 pub additional_context: Option<String>,
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Eq)]
160pub enum HookDecision {
161 #[default]
163 Allow,
164 Deny {
166 reason: String,
168 },
169}
170
171#[derive(Debug, Clone, Default, PartialEq)]
173pub struct HookGate {
174 pub deny: Option<(String, String)>,
176 pub updated_input: Option<serde_json::Value>,
179 pub context: Vec<String>,
181}
182
183pub fn aggregate_hook_responses(responses: Vec<HookResponse>) -> HookGate {
186 let mut gate = HookGate::default();
187 for response in responses {
188 if gate.deny.is_none()
189 && let HookDecision::Deny { reason } = &response.decision
190 {
191 gate.deny = Some((response.plugin.clone(), reason.clone()));
192 }
193 if let Some(input) = response.updated_input {
194 if gate.updated_input.is_some() {
195 tracing::warn!(
196 plugin = %response.plugin,
197 "multiple hooks rewrote the tool input; the last rewrite wins"
198 );
199 }
200 gate.updated_input = Some(input);
201 }
202 if let Some(context) = response.additional_context {
203 gate.context.push(context);
204 }
205 }
206 gate
207}
208
209#[derive(Debug, Deserialize)]
211struct HookWire {
212 #[serde(rename = "hookSpecificOutput")]
213 hook_specific_output: Option<HookSpecificWire>,
214 decision: Option<String>,
216 reason: Option<String>,
217 #[serde(rename = "systemMessage")]
218 system_message: Option<String>,
219}
220
221#[derive(Debug, Deserialize)]
222struct HookSpecificWire {
223 #[serde(rename = "permissionDecision")]
224 permission_decision: Option<String>,
225 #[serde(rename = "permissionDecisionReason")]
226 permission_decision_reason: Option<String>,
227 #[serde(rename = "updatedInput")]
229 updated_input: Option<serde_json::Value>,
230 #[serde(rename = "additionalContext")]
232 additional_context: Option<String>,
233}
234
235pub fn run_plugin_hooks(event: &str, payload: &serde_json::Value) -> Result<Vec<HookResponse>> {
240 let store = RuntimeStore::open_default()?;
241 let payload_bytes = std::sync::Arc::new(serde_json::to_string(payload)?.into_bytes());
242 let mut responses = Vec::new();
243 for plugin in store.plugins().list()? {
244 if !plugin.enabled {
249 continue;
250 }
251 let Ok(manifest) = serde_json::from_str::<PluginManifest>(&plugin.manifest_json) else {
252 tracing::warn!(plugin = %plugin.name, "skipping plugin with unparseable manifest");
253 continue;
254 };
255 let Ok(root) = std::fs::canonicalize(&plugin.source) else {
257 tracing::warn!(plugin = %plugin.name, "plugin source missing; skipping hooks");
258 continue;
259 };
260 responses.extend(run_hooks_for_plugin(
261 &root,
262 &manifest.hooks,
263 &plugin.name,
264 event,
265 &payload_bytes,
266 ));
267 }
268 Ok(responses)
269}
270
271fn run_hooks_for_plugin(
275 root: &Path,
276 hooks: &[String],
277 plugin_name: &str,
278 event: &str,
279 payload_bytes: &std::sync::Arc<Vec<u8>>,
280) -> Vec<HookResponse> {
281 let mut responses = Vec::new();
282 for hook in hooks {
283 let Ok(canonical_hook) = std::fs::canonicalize(root.join(hook)) else {
287 continue; };
289 if !canonical_hook.starts_with(root) {
290 tracing::warn!(plugin = %plugin_name, hook = %hook, "plugin hook escapes root; skipping");
291 continue;
292 }
293 let spawn = Command::new(&canonical_hook)
299 .env_clear()
300 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
301 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
302 .env("MERMAID_HOOK_EVENT", event)
303 .env("MERMAID_PLUGIN_NAME", plugin_name)
304 .stdin(Stdio::piped())
305 .stdout(Stdio::piped())
306 .stderr(Stdio::piped())
307 .spawn();
308 let mut child = match spawn {
309 Ok(child) => child,
310 Err(err) => {
311 tracing::warn!(plugin = %plugin_name, error = %err, "failed to spawn plugin hook");
312 continue; },
314 };
315 if let Some(mut stdin) = child.stdin.take() {
324 let payload = std::sync::Arc::clone(payload_bytes);
325 std::thread::spawn(move || {
326 let _ = stdin.write_all(&payload);
327 });
328 }
329 let stdout_reader = child.stdout.take().map(spawn_capped_reader);
334 let stderr_reader = child.stderr.take().map(spawn_capped_reader);
335 let status = wait_hook_bounded(&mut child, plugin_name, hook, HOOK_TIMEOUT);
336 let stdout = join_reader(stdout_reader);
337 let stderr = join_reader(stderr_reader);
338 responses.push(parse_hook_output(
339 plugin_name,
340 hook,
341 &stdout,
342 &stderr,
343 status,
344 ));
345 }
346 responses
347}
348
349fn spawn_capped_reader<R: Read + Send + 'static>(
352 mut stream: R,
353) -> std::thread::JoinHandle<Vec<u8>> {
354 std::thread::spawn(move || {
355 let mut kept = Vec::new();
356 let mut chunk = [0u8; 4096];
357 loop {
358 match stream.read(&mut chunk) {
359 Ok(0) | Err(_) => break,
360 Ok(n) => {
361 if kept.len() < HOOK_OUTPUT_CAP {
362 let take = n.min(HOOK_OUTPUT_CAP - kept.len());
363 kept.extend_from_slice(&chunk[..take]);
364 }
365 },
367 }
368 }
369 kept
370 })
371}
372
373fn join_reader(handle: Option<std::thread::JoinHandle<Vec<u8>>>) -> Vec<u8> {
375 handle.and_then(|h| h.join().ok()).unwrap_or_default()
376}
377
378fn parse_hook_output(
381 plugin: &str,
382 hook: &str,
383 stdout: &[u8],
384 stderr: &[u8],
385 status: Option<ExitStatus>,
386) -> HookResponse {
387 let mut response = HookResponse {
388 plugin: plugin.to_string(),
389 hook: hook.to_string(),
390 ..HookResponse::default()
391 };
392 let Some(status) = status else {
394 return response;
395 };
396 match status.code() {
399 Some(HOOK_DENY_EXIT_CODE) => {
400 let reason = String::from_utf8_lossy(stderr).trim().to_string();
401 response.decision = HookDecision::Deny {
402 reason: if reason.is_empty() {
403 format!("hook exited {HOOK_DENY_EXIT_CODE}")
404 } else {
405 reason
406 },
407 };
408 return response;
409 },
410 Some(0) => {},
411 _ => {
412 tracing::warn!(plugin = %plugin, hook = %hook, %status, "plugin hook failed");
413 return response;
414 },
415 }
416 let text = String::from_utf8_lossy(stdout);
417 let text = text.trim();
418 if text.is_empty() {
419 return response; }
421 let Ok(wire) = serde_json::from_str::<HookWire>(text) else {
422 tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook printed unparseable output; ignoring");
424 return response;
425 };
426 let mut deny_reason: Option<String> = None;
427 if let Some(specific) = wire.hook_specific_output {
428 match specific.permission_decision.as_deref() {
429 Some("deny") => {
430 deny_reason = Some(
431 specific
432 .permission_decision_reason
433 .or(wire.system_message.clone())
434 .unwrap_or_else(|| "denied by hook".to_string()),
435 );
436 },
437 Some("ask") => {
441 deny_reason = Some(format!(
442 "{} (hook requested user confirmation, which mermaid does not support; treating as deny)",
443 specific
444 .permission_decision_reason
445 .unwrap_or_else(|| "hook requested confirmation".to_string())
446 ));
447 },
448 _ => {},
449 }
450 response.updated_input = specific.updated_input;
451 response.additional_context = specific.additional_context;
452 }
453 if deny_reason.is_none() && wire.decision.as_deref() == Some("block") {
455 deny_reason = Some(
456 wire.reason
457 .or(wire.system_message)
458 .unwrap_or_else(|| "blocked by hook".to_string()),
459 );
460 }
461 if let Some(reason) = deny_reason {
462 response.decision = HookDecision::Deny { reason };
463 }
464 response
465}
466
467fn wait_hook_bounded(
473 child: &mut std::process::Child,
474 plugin: &str,
475 hook: &str,
476 timeout: Duration,
477) -> Option<ExitStatus> {
478 let deadline = Instant::now() + timeout;
479 loop {
480 match child.try_wait() {
481 Ok(Some(status)) => {
482 return Some(status);
483 },
484 Ok(None) => {
485 if Instant::now() >= deadline {
486 let _ = child.kill();
487 let _ = child.wait();
488 tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook timed out; killed");
489 return None;
490 }
491 std::thread::sleep(Duration::from_millis(20));
492 },
493 Err(err) => {
494 tracing::warn!(plugin = %plugin, error = %err, "plugin hook wait failed");
495 return None;
496 },
497 }
498 }
499}
500
501fn load_plugin_manifest(path: &Path) -> Result<(PathBuf, PathBuf, PluginManifest)> {
502 let resolved = resolve_plugin_source(path)?;
503 let manifest_path = if resolved.is_dir() {
504 resolved.join("plugin.toml")
505 } else {
506 resolved
507 };
508 let root = manifest_path
509 .parent()
510 .context("plugin manifest must have a parent directory")?
511 .to_path_buf();
512 let raw = std::fs::read_to_string(&manifest_path)
513 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
514 let manifest: PluginManifest = toml::from_str(&raw)
515 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
516 Ok((manifest_path, root, manifest))
517}
518
519fn resolve_plugin_source(path: &Path) -> Result<PathBuf> {
520 if path.exists() {
521 return Ok(path.to_path_buf());
522 }
523 let source = path.to_string_lossy();
524 let is_git_url = source.starts_with("https://")
528 || source.starts_with("git@")
529 || source.starts_with("ssh://")
530 || source.ends_with(".git");
531 if !is_git_url {
532 return Ok(path.to_path_buf());
533 }
534
535 anyhow::ensure!(
539 std::env::var("MERMAID_ALLOW_PLUGIN_FETCH").is_ok_and(|v| v == "1" || v == "true"),
540 "refusing to fetch remote plugin source {source:?}: set MERMAID_ALLOW_PLUGIN_FETCH=1 to allow, \
541 or clone it yourself and install from the local path",
542 );
543
544 let git_source = source.to_string();
545 let dest = data_dir()?
546 .join("plugins")
547 .join("sources")
548 .join(crate::hex_lower(&Sha256::digest(git_source.as_bytes())));
549 const HARDENING: [&str; 4] = [
553 "-c",
554 "core.hooksPath=/dev/null",
555 "-c",
556 "protocol.ext.allow=never",
557 ];
558 if dest.exists() {
559 let _ = Command::new("git")
560 .env("GIT_TERMINAL_PROMPT", "0")
561 .args(HARDENING)
562 .arg("-C")
563 .arg(&dest)
564 .args(["pull", "--ff-only"])
565 .status();
566 } else {
567 if let Some(parent) = dest.parent() {
568 std::fs::create_dir_all(parent)?;
569 }
570 let status = Command::new("git")
571 .env("GIT_TERMINAL_PROMPT", "0")
572 .args(HARDENING)
573 .args(["clone", "--depth", "1"])
574 .arg(&git_source)
575 .arg(&dest)
576 .status()
577 .with_context(|| format!("failed to clone plugin source {}", git_source))?;
578 anyhow::ensure!(status.success(), "git clone failed for {}", git_source);
579 }
580 Ok(dest)
581}
582
583fn ensure_relative_paths(kind: &str, paths: &[String], root: &Path) -> Result<()> {
584 for path in paths {
585 let rel = Path::new(path);
586 anyhow::ensure!(
587 !rel.is_absolute() && !path.contains(".."),
588 "{} path must stay inside plugin root: {}",
589 kind,
590 path
591 );
592 let full = root.join(rel);
593 anyhow::ensure!(
594 full.exists(),
595 "{} path does not exist under plugin root: {}",
596 kind,
597 path
598 );
599 }
600 Ok(())
601}
602
603#[cfg(test)]
604mod tests {
605 use super::{parse_hook_output, run_hooks_for_plugin};
606 use crate::*;
607
608 #[test]
609 fn manifest_rejects_parent_escape() {
610 let root = std::env::temp_dir();
611 let manifest = PluginManifest {
612 name: "bad".to_string(),
613 version: None,
614 description: None,
615 skills: vec!["../x".to_string()],
616 agents: vec![],
617 hooks: vec![],
618 mcp: vec![],
619 capabilities: vec![],
620 prompts: vec![],
621 bin: vec![],
622 };
623 assert!(validate_plugin_manifest(&manifest, &root).is_err());
624 }
625
626 #[test]
627 fn manifest_round_trips_capabilities_field() {
628 let toml_src = r#"
629 name = "demo"
630 capabilities = ["network", "filesystem"]
631 "#;
632 let manifest: PluginManifest = toml::from_str(toml_src).expect("parse manifest");
633 assert_eq!(manifest.capabilities, vec!["network", "filesystem"]);
634 let json = serde_json::to_string(&manifest).expect("serialize");
636 assert!(json.contains("\"capabilities\""));
637 assert!(!json.contains("\"permissions\""));
638 }
639
640 fn wire(stdout: &str) -> HookResponse {
641 parse_hook_output("p", "h", stdout.as_bytes(), b"", Some(exit_status(0)))
643 }
644
645 fn exit_status(code: i32) -> std::process::ExitStatus {
648 #[cfg(unix)]
649 {
650 std::process::Command::new("sh")
651 .arg("-c")
652 .arg(format!("exit {code}"))
653 .status()
654 .expect("sh exit")
655 }
656 #[cfg(windows)]
657 {
658 std::process::Command::new("cmd")
659 .args(["/C", &format!("exit {code}")])
660 .status()
661 .expect("cmd exit")
662 }
663 }
664
665 #[test]
666 fn parse_permission_decision_shapes() {
667 let r = wire(
669 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no writes on friday"}}"#,
670 );
671 assert_eq!(
672 r.decision,
673 HookDecision::Deny {
674 reason: "no writes on friday".to_string()
675 }
676 );
677 let r = wire(
679 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"ls -la"},"additionalContext":"prefer -la"}}"#,
680 );
681 assert_eq!(r.decision, HookDecision::Allow);
682 assert_eq!(r.updated_input.unwrap()["command"], "ls -la");
683 assert_eq!(r.additional_context.as_deref(), Some("prefer -la"));
684 let r = wire(
686 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs review"}}"#,
687 );
688 match r.decision {
689 HookDecision::Deny { reason } => {
690 assert!(reason.contains("needs review"));
691 assert!(reason.contains("treating as deny"));
692 },
693 other => panic!("ask must deny, got {other:?}"),
694 }
695 }
696
697 #[test]
698 fn parse_legacy_block_shape_and_silent_and_garbage() {
699 let r = wire(r#"{"decision":"block","reason":"legacy nope"}"#);
700 assert_eq!(
701 r.decision,
702 HookDecision::Deny {
703 reason: "legacy nope".to_string()
704 }
705 );
706 assert_eq!(wire("").decision, HookDecision::Allow);
708 assert_eq!(wire("not json at all").decision, HookDecision::Allow);
709 }
710
711 #[test]
712 fn parse_exit_codes() {
713 let r = parse_hook_output("p", "h", b"", b"policy violation\n", Some(exit_status(2)));
715 assert_eq!(
716 r.decision,
717 HookDecision::Deny {
718 reason: "policy violation".to_string()
719 }
720 );
721 let r = parse_hook_output("p", "h", b"", b"", Some(exit_status(2)));
723 assert!(matches!(r.decision, HookDecision::Deny { .. }));
724 let r = parse_hook_output("p", "h", b"", b"boom", Some(exit_status(1)));
726 assert_eq!(r.decision, HookDecision::Allow);
727 let r = parse_hook_output("p", "h", b"", b"", None);
729 assert_eq!(r.decision, HookDecision::Allow);
730 }
731
732 #[test]
733 fn aggregate_first_deny_last_rewrite_ordered_context() {
734 let responses = vec![
735 HookResponse {
736 plugin: "a".into(),
737 additional_context: Some("ctx-a".into()),
738 updated_input: Some(serde_json::json!({"v": 1})),
739 ..HookResponse::default()
740 },
741 HookResponse {
742 plugin: "b".into(),
743 decision: HookDecision::Deny {
744 reason: "first deny".into(),
745 },
746 ..HookResponse::default()
747 },
748 HookResponse {
749 plugin: "c".into(),
750 decision: HookDecision::Deny {
751 reason: "second deny".into(),
752 },
753 updated_input: Some(serde_json::json!({"v": 2})),
754 additional_context: Some("ctx-c".into()),
755 ..HookResponse::default()
756 },
757 ];
758 let gate = aggregate_hook_responses(responses);
759 assert_eq!(gate.deny, Some(("b".to_string(), "first deny".to_string())));
760 assert_eq!(gate.updated_input.unwrap()["v"], 2);
761 assert_eq!(gate.context, vec!["ctx-a".to_string(), "ctx-c".to_string()]);
762 }
763
764 #[cfg(unix)]
765 #[test]
766 fn fixture_scripts_deny_via_json_and_exit2_and_timeout_allows() {
767 use std::os::unix::fs::PermissionsExt;
768 let dir = std::env::temp_dir().join(format!(
769 "mermaid_hook_fixtures_{}_{}",
770 std::process::id(),
771 std::time::SystemTime::now()
772 .duration_since(std::time::UNIX_EPOCH)
773 .unwrap()
774 .as_nanos()
775 ));
776 std::fs::create_dir_all(&dir).unwrap();
777 let write_script = |name: &str, body: &str| {
778 let path = dir.join(name);
779 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
780 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
781 name.to_string()
782 };
783 let hooks = vec![
784 write_script(
785 "deny_json.sh",
786 r#"echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"json says no"}}'"#,
787 ),
788 write_script("deny_exit2.sh", "echo 'stderr says no' >&2; exit 2"),
789 write_script("silent_ok.sh", "exit 0"),
790 ];
791 let payload = std::sync::Arc::new(b"{}".to_vec());
792 let root = std::fs::canonicalize(&dir).unwrap();
793 let responses = run_hooks_for_plugin(&root, &hooks, "fixture", "before_tool_use", &payload);
794 assert_eq!(responses.len(), 3);
795 assert_eq!(
796 responses[0].decision,
797 HookDecision::Deny {
798 reason: "json says no".to_string()
799 }
800 );
801 assert_eq!(
802 responses[1].decision,
803 HookDecision::Deny {
804 reason: "stderr says no".to_string()
805 }
806 );
807 assert_eq!(responses[2].decision, HookDecision::Allow);
808 let _ = std::fs::remove_dir_all(&dir);
809 }
810
811 #[test]
812 fn hook_overrunning_timeout_is_killed() {
813 use std::time::{Duration, Instant};
814 #[cfg(unix)]
817 let mut child = std::process::Command::new("sh")
818 .arg("-c")
819 .arg("sleep 10")
820 .spawn()
821 .expect("spawn sleep");
822 #[cfg(windows)]
823 let mut child = std::process::Command::new("cmd")
824 .args(["/C", "ping -n 11 127.0.0.1 >NUL"])
825 .spawn()
826 .expect("spawn ping");
827 let start = Instant::now();
828 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_millis(150));
829 assert!(
830 start.elapsed() < Duration::from_secs(3),
831 "should return promptly after killing the overrunning hook"
832 );
833 }
834
835 #[test]
836 fn hook_that_exits_quickly_returns_without_kill() {
837 use std::time::{Duration, Instant};
838 #[cfg(unix)]
839 let mut child = std::process::Command::new("true")
840 .spawn()
841 .expect("spawn true");
842 #[cfg(windows)]
843 let mut child = std::process::Command::new("cmd")
844 .args(["/C", "exit 0"])
845 .spawn()
846 .expect("spawn exit");
847 let start = Instant::now();
848 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_secs(30));
849 assert!(start.elapsed() < Duration::from_secs(5));
850 }
851}