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<()> {
78 anyhow::ensure!(!manifest.name.trim().is_empty(), "plugin name is required");
79 ensure_relative_paths("skills", &manifest.skills, root)?;
80 ensure_relative_paths("agents", &manifest.agents, root)?;
81 ensure_relative_paths("hooks", &manifest.hooks, root)?;
82 ensure_relative_paths("mcp", &manifest.mcp, root)?;
83 ensure_relative_paths("prompts", &manifest.prompts, root)?;
84 ensure_relative_paths("bin", &manifest.bin, root)?;
85 Ok(())
86}
87
88pub fn install_plugin_from_path(path: &Path) -> Result<PluginInstallRecord> {
99 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
100 validate_plugin_manifest(&manifest, &root)?;
101 let manifest_json = serde_json::to_string_pretty(&manifest)?;
102 let store = RuntimeStore::open_default()?;
103 let record = store.plugins().install(NewPluginInstall {
104 id: Some(manifest.name.clone()),
105 name: manifest.name,
106 source: root.display().to_string(),
107 version: manifest.version,
108 enabled: false,
113 manifest_json,
114 })?;
115 write_plugin_lockfile()?;
116 Ok(record)
117}
118
119pub fn plugin_capability_preview(path: &Path) -> Result<PluginCapabilityPreview> {
128 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
129 validate_plugin_manifest(&manifest, &root)?;
130 let capabilities_path = root.join("capabilities.toml");
131 let capabilities_toml = if capabilities_path.exists() {
132 let raw = std::fs::read_to_string(&capabilities_path)
133 .with_context(|| format!("failed to read {}", capabilities_path.display()))?;
134 Some(toml::from_str(&raw)?)
135 } else {
136 None
137 };
138 Ok(PluginCapabilityPreview {
139 name: manifest.name,
140 declared_capabilities: manifest.capabilities,
141 capabilities_toml,
142 hooks: manifest.hooks,
143 mcp: manifest.mcp,
144 bin: manifest.bin,
145 })
146}
147
148pub fn write_plugin_lockfile() -> Result<PathBuf> {
156 let store = RuntimeStore::open_default()?;
157 let plugins = store.plugins().list()?;
158 let path = data_dir()?.join("plugins.lock.json");
159 if let Some(parent) = path.parent() {
160 std::fs::create_dir_all(parent)?;
161 }
162 crate::write_atomic(&path, &serde_json::to_vec_pretty(&plugins)?)?;
164 Ok(path)
165}
166
167#[derive(Debug, Clone, Default, PartialEq)]
171pub struct HookResponse {
172 pub plugin: String,
174 pub hook: String,
176 pub decision: HookDecision,
178 pub updated_input: Option<serde_json::Value>,
180 pub additional_context: Option<String>,
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Eq)]
193pub enum HookDecision {
194 #[default]
196 Allow,
197 Deny {
199 reason: String,
201 },
202}
203
204#[derive(Debug, Clone, Default, PartialEq)]
206pub struct HookGate {
207 pub deny: Option<(String, String)>,
209 pub updated_input: Option<serde_json::Value>,
212 pub context: Vec<String>,
214}
215
216pub fn aggregate_hook_responses(responses: Vec<HookResponse>) -> HookGate {
219 let mut gate = HookGate::default();
220 for response in responses {
221 if gate.deny.is_none()
222 && let HookDecision::Deny { reason } = &response.decision
223 {
224 gate.deny = Some((response.plugin.clone(), reason.clone()));
225 }
226 if let Some(input) = response.updated_input {
227 if gate.updated_input.is_some() {
228 tracing::warn!(
229 plugin = %response.plugin,
230 "multiple hooks rewrote the tool input; the last rewrite wins"
231 );
232 }
233 gate.updated_input = Some(input);
234 }
235 if let Some(context) = response.additional_context {
236 gate.context.push(context);
237 }
238 }
239 gate
240}
241
242#[derive(Debug, Deserialize)]
244struct HookWire {
245 #[serde(rename = "hookSpecificOutput")]
246 hook_specific_output: Option<HookSpecificWire>,
247 decision: Option<String>,
249 reason: Option<String>,
250 #[serde(rename = "systemMessage")]
251 system_message: Option<String>,
252}
253
254#[derive(Debug, Deserialize)]
255struct HookSpecificWire {
256 #[serde(rename = "permissionDecision")]
257 permission_decision: Option<String>,
258 #[serde(rename = "permissionDecisionReason")]
259 permission_decision_reason: Option<String>,
260 #[serde(rename = "updatedInput")]
262 updated_input: Option<serde_json::Value>,
263 #[serde(rename = "additionalContext")]
265 additional_context: Option<String>,
266}
267
268pub fn run_plugin_hooks(event: &str, payload: &serde_json::Value) -> Result<Vec<HookResponse>> {
281 let store = RuntimeStore::open_default()?;
282 let payload_bytes = std::sync::Arc::new(serde_json::to_string(payload)?.into_bytes());
283 let mut responses = Vec::new();
284 for plugin in store.plugins().list()? {
285 if !plugin.enabled {
290 continue;
291 }
292 let Ok(manifest) = serde_json::from_str::<PluginManifest>(&plugin.manifest_json) else {
293 tracing::warn!(plugin = %plugin.name, "skipping plugin with unparseable manifest");
294 continue;
295 };
296 let Ok(root) = std::fs::canonicalize(&plugin.source) else {
298 tracing::warn!(plugin = %plugin.name, "plugin source missing; skipping hooks");
299 continue;
300 };
301 responses.extend(run_hooks_for_plugin(
302 &root,
303 &manifest.hooks,
304 &plugin.name,
305 event,
306 &payload_bytes,
307 ));
308 }
309 Ok(responses)
310}
311
312fn run_hooks_for_plugin(
316 root: &Path,
317 hooks: &[String],
318 plugin_name: &str,
319 event: &str,
320 payload_bytes: &std::sync::Arc<Vec<u8>>,
321) -> Vec<HookResponse> {
322 let mut responses = Vec::new();
323 for hook in hooks {
324 let Ok(canonical_hook) = std::fs::canonicalize(root.join(hook)) else {
328 continue; };
330 if !canonical_hook.starts_with(root) {
331 tracing::warn!(plugin = %plugin_name, hook = %hook, "plugin hook escapes root; skipping");
332 continue;
333 }
334 let spawn = Command::new(&canonical_hook)
340 .env_clear()
341 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
342 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
343 .env("MERMAID_HOOK_EVENT", event)
344 .env("MERMAID_PLUGIN_NAME", plugin_name)
345 .stdin(Stdio::piped())
346 .stdout(Stdio::piped())
347 .stderr(Stdio::piped())
348 .spawn();
349 let mut child = match spawn {
350 Ok(child) => child,
351 Err(err) => {
352 tracing::warn!(plugin = %plugin_name, error = %err, "failed to spawn plugin hook");
353 continue; },
355 };
356 if let Some(mut stdin) = child.stdin.take() {
365 let payload = std::sync::Arc::clone(payload_bytes);
366 std::thread::spawn(move || {
367 let _ = stdin.write_all(&payload);
368 });
369 }
370 let stdout_reader = child.stdout.take().map(spawn_capped_reader);
375 let stderr_reader = child.stderr.take().map(spawn_capped_reader);
376 let status = wait_hook_bounded(&mut child, plugin_name, hook, HOOK_TIMEOUT);
377 let stdout = join_reader(stdout_reader);
378 let stderr = join_reader(stderr_reader);
379 responses.push(parse_hook_output(
380 plugin_name,
381 hook,
382 &stdout,
383 &stderr,
384 status,
385 ));
386 }
387 responses
388}
389
390fn spawn_capped_reader<R: Read + Send + 'static>(
393 mut stream: R,
394) -> std::thread::JoinHandle<Vec<u8>> {
395 std::thread::spawn(move || {
396 let mut kept = Vec::new();
397 let mut chunk = [0u8; 4096];
398 loop {
399 match stream.read(&mut chunk) {
400 Ok(0) | Err(_) => break,
401 Ok(n) => {
402 if kept.len() < HOOK_OUTPUT_CAP {
403 let take = n.min(HOOK_OUTPUT_CAP - kept.len());
404 kept.extend_from_slice(&chunk[..take]);
405 }
406 },
408 }
409 }
410 kept
411 })
412}
413
414fn join_reader(handle: Option<std::thread::JoinHandle<Vec<u8>>>) -> Vec<u8> {
416 handle.and_then(|h| h.join().ok()).unwrap_or_default()
417}
418
419fn parse_hook_output(
422 plugin: &str,
423 hook: &str,
424 stdout: &[u8],
425 stderr: &[u8],
426 status: Option<ExitStatus>,
427) -> HookResponse {
428 let mut response = HookResponse {
429 plugin: plugin.to_string(),
430 hook: hook.to_string(),
431 ..HookResponse::default()
432 };
433 let Some(status) = status else {
435 return response;
436 };
437 match status.code() {
440 Some(HOOK_DENY_EXIT_CODE) => {
441 let reason = String::from_utf8_lossy(stderr).trim().to_string();
442 response.decision = HookDecision::Deny {
443 reason: if reason.is_empty() {
444 format!("hook exited {HOOK_DENY_EXIT_CODE}")
445 } else {
446 reason
447 },
448 };
449 return response;
450 },
451 Some(0) => {},
452 _ => {
453 tracing::warn!(plugin = %plugin, hook = %hook, %status, "plugin hook failed");
454 return response;
455 },
456 }
457 let text = String::from_utf8_lossy(stdout);
458 let text = text.trim();
459 if text.is_empty() {
460 return response; }
462 let Ok(wire) = serde_json::from_str::<HookWire>(text) else {
463 tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook printed unparseable output; ignoring");
465 return response;
466 };
467 let mut deny_reason: Option<String> = None;
468 if let Some(specific) = wire.hook_specific_output {
469 match specific.permission_decision.as_deref() {
470 Some("deny") => {
471 deny_reason = Some(
472 specific
473 .permission_decision_reason
474 .or(wire.system_message.clone())
475 .unwrap_or_else(|| "denied by hook".to_string()),
476 );
477 },
478 Some("ask") => {
482 deny_reason = Some(format!(
483 "{} (hook requested user confirmation, which mermaid does not support; treating as deny)",
484 specific
485 .permission_decision_reason
486 .unwrap_or_else(|| "hook requested confirmation".to_string())
487 ));
488 },
489 _ => {},
490 }
491 response.updated_input = specific.updated_input;
492 response.additional_context = specific.additional_context;
493 }
494 if deny_reason.is_none() && wire.decision.as_deref() == Some("block") {
496 deny_reason = Some(
497 wire.reason
498 .or(wire.system_message)
499 .unwrap_or_else(|| "blocked by hook".to_string()),
500 );
501 }
502 if let Some(reason) = deny_reason {
503 response.decision = HookDecision::Deny { reason };
504 }
505 response
506}
507
508fn wait_hook_bounded(
514 child: &mut std::process::Child,
515 plugin: &str,
516 hook: &str,
517 timeout: Duration,
518) -> Option<ExitStatus> {
519 let deadline = Instant::now() + timeout;
520 loop {
521 match child.try_wait() {
522 Ok(Some(status)) => {
523 return Some(status);
524 },
525 Ok(None) => {
526 if Instant::now() >= deadline {
527 let _ = child.kill();
528 let _ = child.wait();
529 tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook timed out; killed");
530 return None;
531 }
532 std::thread::sleep(Duration::from_millis(20));
533 },
534 Err(err) => {
535 tracing::warn!(plugin = %plugin, error = %err, "plugin hook wait failed");
536 return None;
537 },
538 }
539 }
540}
541
542fn load_plugin_manifest(path: &Path) -> Result<(PathBuf, PathBuf, PluginManifest)> {
543 let resolved = resolve_plugin_source(path)?;
544 let manifest_path = if resolved.is_dir() {
545 resolved.join("plugin.toml")
546 } else {
547 resolved
548 };
549 let root = manifest_path
550 .parent()
551 .context("plugin manifest must have a parent directory")?
552 .to_path_buf();
553 let raw = std::fs::read_to_string(&manifest_path)
554 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
555 let manifest: PluginManifest = toml::from_str(&raw)
556 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
557 Ok((manifest_path, root, manifest))
558}
559
560fn resolve_plugin_source(path: &Path) -> Result<PathBuf> {
561 if path.exists() {
562 return Ok(path.to_path_buf());
563 }
564 let source = path.to_string_lossy();
565 let is_git_url = source.starts_with("https://")
569 || source.starts_with("git@")
570 || source.starts_with("ssh://")
571 || source.ends_with(".git");
572 if !is_git_url {
573 return Ok(path.to_path_buf());
574 }
575
576 anyhow::ensure!(
580 std::env::var("MERMAID_ALLOW_PLUGIN_FETCH").is_ok_and(|v| v == "1" || v == "true"),
581 "refusing to fetch remote plugin source {source:?}: set MERMAID_ALLOW_PLUGIN_FETCH=1 to allow, \
582 or clone it yourself and install from the local path",
583 );
584
585 let git_source = source.to_string();
586 let dest = data_dir()?
587 .join("plugins")
588 .join("sources")
589 .join(crate::hex_lower(&Sha256::digest(git_source.as_bytes())));
590 if dest.exists() {
594 let _ = crate::git::git(&dest).args(["pull", "--ff-only"]).run();
597 } else {
598 if let Some(parent) = dest.parent() {
599 std::fs::create_dir_all(parent)?;
600 }
601 crate::git::GitCommand::new()
602 .args(["clone", "--depth", "1"])
603 .arg(&git_source)
604 .arg(&dest)
605 .run()
606 .with_context(|| format!("failed to clone plugin source {git_source}"))?;
607 }
608 Ok(dest)
609}
610
611fn ensure_relative_paths(kind: &str, paths: &[String], root: &Path) -> Result<()> {
612 for path in paths {
613 let rel = Path::new(path);
614 anyhow::ensure!(
615 !rel.is_absolute() && !path.contains(".."),
616 "{kind} path must stay inside plugin root: {path}"
617 );
618 let full = root.join(rel);
619 anyhow::ensure!(
620 full.exists(),
621 "{kind} path does not exist under plugin root: {path}"
622 );
623 }
624 Ok(())
625}
626
627#[cfg(test)]
628mod tests {
629 use super::parse_hook_output;
630 use crate::*;
631
632 #[test]
633 fn manifest_rejects_parent_escape() {
634 let root = std::env::temp_dir();
635 let manifest = PluginManifest {
636 name: "bad".to_string(),
637 version: None,
638 description: None,
639 skills: vec!["../x".to_string()],
640 agents: vec![],
641 hooks: vec![],
642 mcp: vec![],
643 capabilities: vec![],
644 prompts: vec![],
645 bin: vec![],
646 };
647 assert!(validate_plugin_manifest(&manifest, &root).is_err());
648 }
649
650 #[test]
651 fn manifest_round_trips_capabilities_field() {
652 let toml_src = r#"
653 name = "demo"
654 capabilities = ["network", "filesystem"]
655 "#;
656 let manifest: PluginManifest = toml::from_str(toml_src).expect("parse manifest");
657 assert_eq!(manifest.capabilities, vec!["network", "filesystem"]);
658 let json = serde_json::to_string(&manifest).expect("serialize");
660 assert!(json.contains("\"capabilities\""));
661 assert!(!json.contains("\"permissions\""));
662 }
663
664 fn wire(stdout: &str) -> HookResponse {
665 parse_hook_output("p", "h", stdout.as_bytes(), b"", Some(exit_status(0)))
667 }
668
669 fn exit_status(code: i32) -> std::process::ExitStatus {
672 #[cfg(unix)]
673 {
674 std::process::Command::new("sh")
675 .arg("-c")
676 .arg(format!("exit {code}"))
677 .status()
678 .expect("sh exit")
679 }
680 #[cfg(windows)]
681 {
682 std::process::Command::new("cmd")
683 .args(["/C", &format!("exit {code}")])
684 .status()
685 .expect("cmd exit")
686 }
687 }
688
689 #[test]
690 fn parse_permission_decision_shapes() {
691 let r = wire(
693 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no writes on friday"}}"#,
694 );
695 assert_eq!(
696 r.decision,
697 HookDecision::Deny {
698 reason: "no writes on friday".to_string()
699 }
700 );
701 let r = wire(
703 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"ls -la"},"additionalContext":"prefer -la"}}"#,
704 );
705 assert_eq!(r.decision, HookDecision::Allow);
706 assert_eq!(r.updated_input.unwrap()["command"], "ls -la");
707 assert_eq!(r.additional_context.as_deref(), Some("prefer -la"));
708 let r = wire(
710 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs review"}}"#,
711 );
712 match r.decision {
713 HookDecision::Deny { reason } => {
714 assert!(reason.contains("needs review"));
715 assert!(reason.contains("treating as deny"));
716 },
717 other => panic!("ask must deny, got {other:?}"),
718 }
719 }
720
721 #[test]
722 fn parse_legacy_block_shape_and_silent_and_garbage() {
723 let r = wire(r#"{"decision":"block","reason":"legacy nope"}"#);
724 assert_eq!(
725 r.decision,
726 HookDecision::Deny {
727 reason: "legacy nope".to_string()
728 }
729 );
730 assert_eq!(wire("").decision, HookDecision::Allow);
732 assert_eq!(wire("not json at all").decision, HookDecision::Allow);
733 }
734
735 #[test]
736 fn parse_exit_codes() {
737 let r = parse_hook_output("p", "h", b"", b"policy violation\n", Some(exit_status(2)));
739 assert_eq!(
740 r.decision,
741 HookDecision::Deny {
742 reason: "policy violation".to_string()
743 }
744 );
745 let r = parse_hook_output("p", "h", b"", b"", Some(exit_status(2)));
747 assert!(matches!(r.decision, HookDecision::Deny { .. }));
748 let r = parse_hook_output("p", "h", b"", b"boom", Some(exit_status(1)));
750 assert_eq!(r.decision, HookDecision::Allow);
751 let r = parse_hook_output("p", "h", b"", b"", None);
753 assert_eq!(r.decision, HookDecision::Allow);
754 }
755
756 #[test]
757 fn aggregate_first_deny_last_rewrite_ordered_context() {
758 let responses = vec![
759 HookResponse {
760 plugin: "a".into(),
761 additional_context: Some("ctx-a".into()),
762 updated_input: Some(serde_json::json!({"v": 1})),
763 ..HookResponse::default()
764 },
765 HookResponse {
766 plugin: "b".into(),
767 decision: HookDecision::Deny {
768 reason: "first deny".into(),
769 },
770 ..HookResponse::default()
771 },
772 HookResponse {
773 plugin: "c".into(),
774 decision: HookDecision::Deny {
775 reason: "second deny".into(),
776 },
777 updated_input: Some(serde_json::json!({"v": 2})),
778 additional_context: Some("ctx-c".into()),
779 ..HookResponse::default()
780 },
781 ];
782 let gate = aggregate_hook_responses(responses);
783 assert_eq!(gate.deny, Some(("b".to_string(), "first deny".to_string())));
784 assert_eq!(gate.updated_input.unwrap()["v"], 2);
785 assert_eq!(gate.context, vec!["ctx-a".to_string(), "ctx-c".to_string()]);
786 }
787
788 #[cfg(unix)]
789 #[test]
790 fn fixture_scripts_deny_via_json_and_exit2_and_timeout_allows() {
791 use std::os::unix::fs::PermissionsExt;
792
793 use super::run_hooks_for_plugin;
794 let dir = std::env::temp_dir().join(format!(
795 "mermaid_hook_fixtures_{}_{}",
796 std::process::id(),
797 std::time::SystemTime::now()
798 .duration_since(std::time::UNIX_EPOCH)
799 .unwrap()
800 .as_nanos()
801 ));
802 std::fs::create_dir_all(&dir).unwrap();
803 let write_script = |name: &str, body: &str| {
804 let path = dir.join(name);
805 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
806 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
807 name.to_string()
808 };
809 let hooks = vec![
810 write_script(
811 "deny_json.sh",
812 r#"echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"json says no"}}'"#,
813 ),
814 write_script("deny_exit2.sh", "echo 'stderr says no' >&2; exit 2"),
815 write_script("silent_ok.sh", "exit 0"),
816 ];
817 let payload = std::sync::Arc::new(b"{}".to_vec());
818 let root = std::fs::canonicalize(&dir).unwrap();
819 let responses = run_hooks_for_plugin(&root, &hooks, "fixture", "before_tool_use", &payload);
820 assert_eq!(responses.len(), 3);
821 assert_eq!(
822 responses[0].decision,
823 HookDecision::Deny {
824 reason: "json says no".to_string()
825 }
826 );
827 assert_eq!(
828 responses[1].decision,
829 HookDecision::Deny {
830 reason: "stderr says no".to_string()
831 }
832 );
833 assert_eq!(responses[2].decision, HookDecision::Allow);
834 let _ = std::fs::remove_dir_all(&dir);
835 }
836
837 #[test]
838 fn hook_overrunning_timeout_is_killed() {
839 use std::time::{Duration, Instant};
840 #[cfg(unix)]
843 let mut child = std::process::Command::new("sh")
844 .arg("-c")
845 .arg("sleep 10")
846 .spawn()
847 .expect("spawn sleep");
848 #[cfg(windows)]
849 let mut child = std::process::Command::new("cmd")
850 .args(["/C", "ping -n 11 127.0.0.1 >NUL"])
851 .spawn()
852 .expect("spawn ping");
853 let start = Instant::now();
854 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_millis(150));
855 assert!(
856 start.elapsed() < Duration::from_secs(3),
857 "should return promptly after killing the overrunning hook"
858 );
859 }
860
861 #[test]
862 fn hook_that_exits_quickly_returns_without_kill() {
863 use std::time::{Duration, Instant};
864 #[cfg(unix)]
865 let mut child = std::process::Command::new("true")
866 .spawn()
867 .expect("spawn true");
868 #[cfg(windows)]
869 let mut child = std::process::Command::new("cmd")
870 .args(["/C", "exit 0"])
871 .spawn()
872 .expect("spawn exit");
873 let start = Instant::now();
874 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_secs(30));
875 assert!(start.elapsed() < Duration::from_secs(5));
876 }
877}