1use std::io::{Read, Write};
15use std::path::PathBuf;
16use std::process::{Command, Stdio};
17use std::thread;
18use std::time::{Duration, Instant};
19
20use serde_json::{Value, json};
21
22use super::ast::Flags;
23
24pub const SUBCOMMAND: &str = "__guard_pattern_probe";
26
27pub const LARGE_SAMPLE_SECONDS: f64 = 0.2;
30
31const REFERENCE_SCAN_PATTERN: &str =
32 r"(?i)/[0-9]*\s*(?:OR|AND|UNION|SELECT|INSERT|DELETE|DROP|CONCAT|CHAR|UPDATE)\b";
33const REFERENCE_SCAN_PROBE_LENGTH: usize = 32000;
34
35#[derive(Debug, Clone)]
37pub enum ChildRequest {
38 TestStrings {
41 pattern: String,
42 test_strings: Vec<String>,
43 threshold: f64,
44 flags: Flags,
45 },
46 ReachTiming {
48 pattern: String,
49 probes: Vec<String>,
50 samples: usize,
51 deadline: f64,
52 flags: Flags,
53 trigger: f64,
54 },
55 ReferenceLoad,
57 StrayVerify {
59 pattern: String,
60 flags: Flags,
61 cases: Vec<(String, Vec<String>)>,
62 },
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub enum ChildOutcome {
68 Safety { safe: bool, reason: String },
70 Timing {
72 results: Vec<Vec<f64>>,
73 reference: f64,
74 },
75 Reference { reference: f64 },
77 Stray(Option<String>),
79 Failed(String),
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ChildSpawnError {
86 Timeout,
88 Failed(String),
90}
91
92impl std::fmt::Display for ChildSpawnError {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Self::Timeout => write!(f, "child deadline elapsed"),
96 Self::Failed(detail) => write!(f, "child failed: {detail}"),
97 }
98 }
99}
100
101impl std::error::Error for ChildSpawnError {}
102
103fn flags_value(flags: &Flags) -> Value {
104 json!({
105 "i": flags.ignorecase,
106 "m": flags.multiline,
107 "s": flags.dotall,
108 "a": flags.ascii,
109 })
110}
111
112fn flags_from_value(value: &Value) -> Flags {
113 Flags {
114 ignorecase: value.get("i").and_then(Value::as_bool).unwrap_or_default(),
115 multiline: value.get("m").and_then(Value::as_bool).unwrap_or_default(),
116 dotall: value.get("s").and_then(Value::as_bool).unwrap_or_default(),
117 ascii: value.get("a").and_then(Value::as_bool).unwrap_or_default(),
118 }
119}
120
121fn request_payload(request: &ChildRequest) -> Value {
122 match request {
123 ChildRequest::TestStrings {
124 pattern,
125 test_strings,
126 threshold,
127 flags,
128 } => json!({
129 "op": "test_strings",
130 "pattern": pattern,
131 "test_strings": test_strings,
132 "threshold": threshold,
133 "flags": flags_value(flags),
134 }),
135 ChildRequest::ReachTiming {
136 pattern,
137 probes,
138 samples,
139 deadline,
140 flags,
141 trigger,
142 } => json!({
143 "op": "reach_timing",
144 "pattern": pattern,
145 "probes": probes,
146 "samples": samples,
147 "deadline": deadline,
148 "flags": flags_value(flags),
149 "trigger": trigger,
150 }),
151 ChildRequest::ReferenceLoad => json!({ "op": "reference_load" }),
152 ChildRequest::StrayVerify {
153 pattern,
154 flags,
155 cases,
156 } => json!({
157 "op": "stray_verify",
158 "pattern": pattern,
159 "flags": flags_value(flags),
160 "cases": cases
161 .iter()
162 .map(|(candidate, probes)| json!([candidate, probes]))
163 .collect::<Vec<_>>(),
164 }),
165 }
166}
167
168fn parse_outcome(stdout: &str) -> Result<ChildOutcome, ChildSpawnError> {
169 let value: Value = serde_json::from_str(stdout.trim())
170 .map_err(|e| ChildSpawnError::Failed(format!("malformed child output: {e}")))?;
171 if let Some(error) = value.get("error").and_then(Value::as_str) {
172 return Ok(ChildOutcome::Failed(error.to_owned()));
173 }
174 if let (Some(safe), Some(reason)) = (
175 value.get("safe").and_then(Value::as_bool),
176 value.get("reason").and_then(Value::as_str),
177 ) {
178 return Ok(ChildOutcome::Safety {
179 safe,
180 reason: reason.to_owned(),
181 });
182 }
183 if let Some(reference) = value.get("reference").and_then(Value::as_f64) {
184 if let Some(results) = value.get("results") {
185 let rows: Vec<Vec<f64>> = results
186 .as_array()
187 .map(|rows| {
188 rows.iter()
189 .map(|row| {
190 row.as_array()
191 .map(|samples| {
192 samples
193 .iter()
194 .filter_map(Value::as_f64)
195 .collect::<Vec<f64>>()
196 })
197 .unwrap_or_default()
198 })
199 .collect()
200 })
201 .unwrap_or_default();
202 return Ok(ChildOutcome::Timing {
203 results: rows,
204 reference,
205 });
206 }
207 return Ok(ChildOutcome::Reference { reference });
208 }
209 if let Some(candidate) = value.as_str() {
210 return Ok(ChildOutcome::Stray(Some(candidate.to_owned())));
211 }
212 if value.is_null() {
213 return Ok(ChildOutcome::Stray(None));
214 }
215 Err(ChildSpawnError::Failed(format!(
216 "malformed child output: {value}"
217 )))
218}
219
220#[must_use]
222pub fn child_path() -> Option<PathBuf> {
223 let env_override = std::env::var("GUARD_PATTERN_PROBE_BIN").ok();
224 resolve_child_path(
225 env_override.as_deref(),
226 std::env::current_exe().ok().as_deref(),
227 )
228}
229
230#[must_use]
232fn resolve_child_path(
233 env_override: Option<&str>,
234 exe: Option<&std::path::Path>,
235) -> Option<PathBuf> {
236 if let Some(path) = env_override {
237 let path = PathBuf::from(path);
238 if path.exists() {
239 return Some(path);
240 }
241 }
242 let exe = exe?;
243 let exe_name = exe.file_name()?.to_str()?;
244 if exe_name.starts_with("guard-pattern-probe") {
245 return Some(exe.to_path_buf());
246 }
247 #[cfg(not(coverage))] let dir = exe.parent()?;
250 #[cfg(coverage)]
251 let dir = exe
252 .parent()
253 .expect("a named file always has a parent directory");
254 let mut candidates: Vec<PathBuf> = Vec::new();
255 for base in core::iter::once(dir).chain(dir.parent()) {
256 for name in ["guard-pattern-probe", "guard-pattern-probe.exe"] {
259 candidates.push(base.join(name));
260 }
261 }
262 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
267 let workspace_candidates = manifest
268 .parent()
269 .and_then(|crates| crates.parent())
270 .into_iter()
271 .flat_map(|workspace| {
272 ["guard-pattern-probe", "guard-pattern-probe.exe"]
273 .iter()
274 .map(move |name| workspace.join("target/debug").join(name))
275 });
276 candidates.extend(workspace_candidates);
277 candidates.into_iter().find(|candidate| candidate.exists())
278}
279
280pub fn run_child_request(
287 request: &ChildRequest,
288 timeout_secs: f64,
289) -> Result<ChildOutcome, ChildSpawnError> {
290 run_child_request_at(request, timeout_secs, child_path())
291}
292
293pub(crate) fn run_child_request_at(
300 request: &ChildRequest,
301 timeout_secs: f64,
302 child_path: Option<PathBuf>,
303) -> Result<ChildOutcome, ChildSpawnError> {
304 let payload = request_payload(request).to_string();
305 let Some(child_path) = child_path else {
306 return Err(ChildSpawnError::Failed(
307 "probe child binary not found".into(),
308 ));
309 };
310 let mut command = Command::new(&child_path);
311 command
312 .arg(SUBCOMMAND)
313 .stdin(Stdio::piped())
314 .stdout(Stdio::piped())
315 .stderr(Stdio::piped());
316 let mut child = command
317 .spawn()
318 .map_err(|e| ChildSpawnError::Failed(format!("spawn failed: {e}")))?;
319 {
320 let mut stdin = child.stdin.take().expect("piped stdin");
321 thread::spawn(move || {
322 let _ = stdin.write_all(payload.as_bytes());
323 });
324 }
325 let mut stdout_pipe = child.stdout.take().expect("piped stdout");
326 let reader = thread::spawn(move || {
327 let mut buffer = String::new();
328 let _ = stdout_pipe.read_to_string(&mut buffer);
329 buffer
330 });
331 let start = Instant::now();
332 let deadline = Duration::from_secs_f64(timeout_secs.max(0.0));
333 let status = loop {
337 #[cfg(not(coverage))] let waited = child
341 .try_wait()
342 .map_err(|e| ChildSpawnError::Failed(format!("wait failed: {e}")))?;
343 #[cfg(coverage)]
344 let waited = child
345 .try_wait()
346 .expect("ECHILD cannot happen for this process's own child");
347 let Some(status) = waited else {
348 if start.elapsed() >= deadline {
349 let _ = child.kill();
350 let _ = child.wait();
351 break None;
352 }
353 thread::sleep(Duration::from_millis(2));
354 continue;
355 };
356 break Some(status);
357 };
358 let output = reader.join().unwrap_or_default();
359 let Some(status) = status else {
360 return Err(ChildSpawnError::Timeout);
361 };
362 if !status.success() || output.trim().is_empty() {
363 return Err(ChildSpawnError::Failed(format!(
364 "child exited unexpectedly (status {status})"
365 )));
366 }
367 parse_outcome(&output)
368}
369
370fn flag_prefix(flags: &Flags) -> String {
371 let mut letters = String::new();
372 if flags.ignorecase {
373 letters.push('i');
374 }
375 if flags.multiline {
376 letters.push('m');
377 }
378 if flags.dotall {
379 letters.push('s');
380 }
381 if flags.ascii {
382 letters.push('a');
383 }
384 if letters.is_empty() {
385 String::new()
386 } else {
387 format!("(?{letters})")
388 }
389}
390
391pub enum CompiledProbe {
394 Re(regex::Regex),
395 Fancy(fancy_regex::Regex),
396}
397
398impl CompiledProbe {
399 pub fn compile(pattern: &str, flags: &Flags) -> Result<Self, String> {
405 let source = format!("{}{pattern}", flag_prefix(flags));
406 if let Ok(regex) = regex::Regex::new(&source) {
407 return Ok(Self::Re(regex));
408 }
409 fancy_regex::Regex::new(&source)
410 .map(Self::Fancy)
411 .map_err(|e| e.to_string())
412 }
413
414 pub fn timed_search(&self, text: &str) -> f64 {
416 let start = Instant::now();
417 match self {
418 Self::Re(regex) => {
419 let _ = regex.find(text);
420 }
421 Self::Fancy(regex) => {
422 let _ = regex.find(text);
423 }
424 }
425 start.elapsed().as_secs_f64()
426 }
427}
428
429fn reference_scan_times(samples: usize) -> Vec<f64> {
430 #[cfg(not(coverage))] let Ok(reference) = CompiledProbe::compile(REFERENCE_SCAN_PATTERN, &Flags::default()) else {
433 return Vec::new();
434 };
435 #[cfg(coverage)]
436 let reference = CompiledProbe::compile(REFERENCE_SCAN_PATTERN, &Flags::default())
437 .expect("the reference scan pattern is a compile-time constant");
438 let probe = format!("/{}", "0".repeat(REFERENCE_SCAN_PROBE_LENGTH));
439 let mut times = Vec::with_capacity(samples);
440 for _ in 0..samples {
441 times.push(reference.timed_search(&probe));
442 }
443 times
444}
445
446fn child_test_strings(payload: &Value) -> Value {
447 let pattern = payload["pattern"].as_str().unwrap_or_default();
448 let test_strings: Vec<&str> = payload["test_strings"]
449 .as_array()
450 .map(|rows| rows.iter().filter_map(Value::as_str).collect())
451 .unwrap_or_default();
452 let threshold = payload["threshold"].as_f64().unwrap_or(f64::MAX);
453 let flags = flags_from_value(&payload["flags"]);
454 let compiled = match CompiledProbe::compile(pattern, &flags) {
455 Ok(compiled) => compiled,
456 Err(message) => {
457 return json!({
458 "safe": false,
459 "reason": format!("Pattern validation failed: {message}"),
460 });
461 }
462 };
463 for test_str in test_strings {
464 let elapsed = compiled.timed_search(test_str);
465 if elapsed > threshold {
466 return json!({
467 "safe": false,
468 "reason": format!(
469 "Pattern timed out on test string of length {}",
470 test_str.chars().count()
471 ),
472 });
473 }
474 }
475 json!({ "safe": true, "reason": "Pattern appears safe" })
476}
477
478fn child_reach_timing(payload: &Value) -> Value {
479 let pattern = payload["pattern"].as_str().unwrap_or_default();
480 let probes: Vec<&str> = payload["probes"]
481 .as_array()
482 .map(|rows| rows.iter().filter_map(Value::as_str).collect())
483 .unwrap_or_default();
484 let samples = payload["samples"].as_u64().unwrap_or(1).max(1) as usize;
485 let trigger = payload["trigger"].as_f64().unwrap_or(f64::MAX);
486 let flags = flags_from_value(&payload["flags"]);
487 let compiled = match CompiledProbe::compile(pattern, &flags) {
488 Ok(compiled) => compiled,
489 Err(message) => return json!({ "error": message }),
490 };
491 let mut reference_times = reference_scan_times(samples);
492 reference_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
493 let reference = reference_times.first().copied().unwrap_or(0.0);
494 let mut results: Vec<Vec<f64>> = Vec::with_capacity(probes.len());
495 for probe in probes {
496 let mut probe_times = sample_probe(probe, samples, trigger, &mut |text| {
497 compiled.timed_search(text)
498 });
499 probe_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
500 results.push(probe_times);
501 }
502 json!({ "results": results, "reference": reference })
503}
504
505fn sample_probe(
508 probe: &str,
509 samples: usize,
510 trigger: f64,
511 timed_search: &mut dyn FnMut(&str) -> f64,
512) -> Vec<f64> {
513 let mut probe_times = vec![timed_search(probe)];
514 if probe_times[0] >= trigger {
515 for _ in 0..samples.saturating_sub(1) {
516 probe_times.push(timed_search(probe));
517 if *probe_times.last().expect("just pushed") > LARGE_SAMPLE_SECONDS {
518 break;
519 }
520 }
521 }
522 probe_times
523}
524
525fn child_reference_load() -> Value {
526 let mut reference_times = reference_scan_times(5);
527 reference_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
528 json!({ "reference": reference_times.first().copied().unwrap_or(0.0) })
529}
530
531fn child_stray_verify(payload: &Value) -> Value {
532 let pattern = payload["pattern"].as_str().unwrap_or_default();
533 let flags = flags_from_value(&payload["flags"]);
534 let Ok(compiled) = CompiledProbe::compile(pattern, &flags) else {
535 return Value::Null;
536 };
537 let Some(cases) = payload["cases"].as_array() else {
538 return Value::Null;
539 };
540 for case in cases {
541 let candidate = case[0].as_str().unwrap_or_default();
542 let probes = case[1].as_array().cloned().unwrap_or_default();
543 let all_miss = probes
544 .iter()
545 .all(|probe| search_misses(&compiled, probe.as_str().unwrap_or_default()));
546 if all_miss {
547 return json!(candidate);
548 }
549 }
550 Value::Null
551}
552
553fn search_misses(compiled: &CompiledProbe, probe: &str) -> bool {
554 match compiled {
555 CompiledProbe::Re(regex) => !regex.is_match(probe),
556 CompiledProbe::Fancy(regex) => !regex.is_match(probe).unwrap_or(false),
557 }
558}
559
560pub fn child_main(args: &[String]) -> Option<i32> {
563 if args.len() < 2 || args[1] != SUBCOMMAND {
564 return None;
565 }
566 let mut payload = String::new();
567 if std::io::stdin().read_to_string(&mut payload).is_err() {
568 return Some(1);
569 }
570 let output = match serde_json::from_str::<Value>(&payload) {
571 Ok(value) => dispatch_payload(&value),
572 Err(_) => return Some(1),
573 };
574 let mut stdout = std::io::stdout();
575 let _ = stdout.write_all(output.to_string().as_bytes());
576 let _ = stdout.write_all(b"\n");
577 let _ = stdout.flush();
578 Some(0)
579}
580
581#[must_use]
583pub fn dispatch_payload(value: &Value) -> Value {
584 match value["op"].as_str() {
585 Some("test_strings") => child_test_strings(value),
586 Some("reach_timing") => child_reach_timing(value),
587 Some("reference_load") => child_reference_load(),
588 Some("stray_verify") => child_stray_verify(value),
589 _ => Value::Null,
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::redos::cost_arbiter::REFERENCE_LOAD_PROBE_TIMEOUT_SECONDS;
597
598 fn expect_failed(outcome: Result<ChildOutcome, ChildSpawnError>) -> String {
600 match outcome {
601 Err(ChildSpawnError::Failed(detail)) => detail,
602 other => panic!("expected an exit failure, got {other:?}"),
603 }
604 }
605
606 fn expect_fancy(compiled: &CompiledProbe) -> &fancy_regex::Regex {
608 match compiled {
609 CompiledProbe::Fancy(regex) => regex,
610 CompiledProbe::Re(_) => panic!("expected the fancy engine"),
611 }
612 }
613
614 #[test]
615 fn child_path_resolves_inside_the_workspace_target_dir() {
616 let path = child_path().expect("child binary resolvable");
618 assert!(path.exists());
619 }
620
621 #[test]
622 fn flags_round_trip_through_json() {
623 let flags = Flags {
624 ignorecase: true,
625 multiline: false,
626 dotall: true,
627 ascii: false,
628 };
629 let decoded = flags_from_value(&flags_value(&flags));
630 assert_eq!(decoded, flags);
631 assert_eq!(flags_from_value(&json!({})), Flags::default());
632 }
633
634 #[test]
635 fn flag_prefix_omits_an_empty_group() {
636 assert_eq!(flag_prefix(&Flags::default()), "");
637 assert_eq!(flag_prefix(&Flags::ignorecase_multiline()), "(?im)");
638 let dotall = Flags {
639 dotall: true,
640 ..Flags::default()
641 };
642 assert_eq!(flag_prefix(&dotall), "(?s)");
643 }
644
645 #[test]
646 fn parse_outcome_maps_every_child_shape() {
647 let safety =
648 parse_outcome(r#"{"safe": true, "reason": "Pattern appears safe"}"#).expect("outcome");
649 assert!(matches!(
650 safety,
651 ChildOutcome::Safety {
652 safe: true,
653 reason: _
654 }
655 ));
656 let error = parse_outcome(r#"{"error": "bad pattern"}"#).expect("outcome");
657 assert_eq!(error, ChildOutcome::Failed("bad pattern".to_owned()));
658 let reference = parse_outcome(r#"{"reference": 0.5}"#).expect("outcome");
659 assert_eq!(reference, ChildOutcome::Reference { reference: 0.5 });
660 let timing =
661 parse_outcome(r#"{"results": [[0.2, 0.1]], "reference": 0.01}"#).expect("outcome");
662 assert_eq!(
663 timing,
664 ChildOutcome::Timing {
665 results: vec![vec![0.2, 0.1]],
666 reference: 0.01,
667 }
668 );
669 let stray = parse_outcome(r#""\u0000""#).expect("outcome");
670 assert_eq!(
671 stray,
672 ChildOutcome::Stray(Some("\u{0}".to_owned())),
673 "a bare JSON string is the stray candidate text"
674 );
675 let none = parse_outcome("null").expect("outcome");
676 assert_eq!(none, ChildOutcome::Stray(None));
677 assert!(parse_outcome("not json").is_err());
678 assert!(parse_outcome("{}").is_err());
679 }
680
681 #[test]
682 fn timed_search_falls_back_to_fancy_regex() {
683 let compiled = CompiledProbe::compile("(?!x)a", &Flags::default()).expect("fancy compile");
685 expect_fancy(&compiled);
686 assert!(compiled.timed_search("ab") > 0.0);
687 }
688
689 #[test]
690 fn compile_reports_the_engine_error_text() {
691 let error = CompiledProbe::compile("[invalid", &Flags::default())
692 .err()
693 .expect("compile error");
694 assert!(!error.is_empty());
695 }
696
697 #[test]
698 fn run_child_request_kills_a_runaway_child() {
699 let pattern = "(?!x)(?:a|aa)+$";
706 let probes = vec![format!("{}b", "a".repeat(32_000))];
709 let request = ChildRequest::ReachTiming {
710 pattern: pattern.to_owned(),
711 probes,
712 samples: 1,
713 deadline: 1.0,
714 flags: Flags::default(),
715 trigger: f64::MAX,
716 };
717 let start = Instant::now();
718 let outcome = run_child_request(&request, 0.05);
719 assert_eq!(outcome, Err(ChildSpawnError::Timeout));
720 assert!(start.elapsed().as_secs_f64() < 5.0);
722 }
723
724 #[test]
725 fn reference_load_child_reports_a_positive_scan() {
726 let outcome = run_child_request(
727 &ChildRequest::ReferenceLoad,
728 REFERENCE_LOAD_PROBE_TIMEOUT_SECONDS,
729 )
730 .expect("reference load");
731 assert!(
732 matches!(outcome, ChildOutcome::Reference { reference } if reference > 0.0),
733 "{outcome:?}"
734 );
735 }
736
737 fn expect_re(compiled: &CompiledProbe) -> ®ex::Regex {
739 match compiled {
740 CompiledProbe::Re(regex) => regex,
741 CompiledProbe::Fancy(_) => panic!("expected the regex engine"),
742 }
743 }
744
745 #[test]
746 fn compiled_probe_uses_the_regex_crate_first() {
747 let compiled = CompiledProbe::compile("abc", &Flags::default()).expect("compile");
748 assert_eq!(expect_re(&compiled).as_str(), "abc");
749 }
750
751 #[test]
752 fn expect_re_rejects_the_fancy_engine() {
753 let compiled = CompiledProbe::compile("(?!x)a", &Flags::default()).expect("fancy compile");
754 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
755 expect_re(&compiled);
756 }));
757 assert!(result.is_err(), "the fancy engine must be rejected");
758 }
759
760 #[test]
761 fn child_spawn_error_display_covers_both_variants() {
762 assert_eq!(
763 ChildSpawnError::Timeout.to_string(),
764 "child deadline elapsed"
765 );
766 assert_eq!(
767 ChildSpawnError::Failed("boom".to_owned()).to_string(),
768 "child failed: boom"
769 );
770 }
771
772 #[test]
773 fn resolve_child_path_prefers_an_existing_override() {
774 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
775 let override_path = manifest.join("Cargo.toml");
776 let resolved = resolve_child_path(Some(override_path.to_str().expect("utf8")), None);
777 assert_eq!(resolved, Some(override_path));
778 }
779
780 #[test]
781 fn resolve_child_path_without_an_exe_has_nowhere_to_search() {
782 assert_eq!(resolve_child_path(None, None), None);
783 }
784
785 #[test]
786 fn resolve_child_path_rejects_a_nameless_exe() {
787 assert_eq!(
789 resolve_child_path(None, Some(std::path::Path::new("/"))),
790 None
791 );
792 }
793
794 #[cfg(unix)]
795 #[test]
796 fn resolve_child_path_rejects_a_non_utf8_exe_name() {
797 use std::os::unix::ffi::OsStrExt;
798 let exe = std::ffi::OsStr::from_bytes(b"/opt/tools/probe\xff");
799 assert_eq!(
800 resolve_child_path(None, Some(std::path::Path::new(exe))),
801 None
802 );
803 }
804
805 #[test]
806 fn resolve_child_path_accepts_the_probe_binary_itself_as_the_exe() {
807 let exe = PathBuf::from("/opt/tools/guard-pattern-probe");
808 let resolved = resolve_child_path(Some("/definitely/missing/bin"), Some(&exe));
809 assert_eq!(resolved, Some(exe));
810 }
811
812 #[test]
813 fn resolve_child_path_skips_a_missing_override() {
814 let exe = PathBuf::from("/opt/tools/other-binary");
815 let resolved = resolve_child_path(Some("/definitely/missing/bin"), Some(&exe));
819 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
820 let workspace = manifest
821 .parent()
822 .and_then(|crates| crates.parent())
823 .map(|root| root.join("target/debug/guard-pattern-probe"))
824 .filter(|candidate| candidate.exists());
825 assert_eq!(resolved, workspace);
826 }
827
828 #[test]
829 fn a_missing_child_path_is_a_spawn_failure() {
830 let outcome = run_child_request_at(&ChildRequest::ReferenceLoad, 1.0, None);
831 assert_eq!(
832 outcome,
833 Err(ChildSpawnError::Failed(
834 "probe child binary not found".into()
835 ))
836 );
837 }
838
839 #[test]
840 fn a_non_executable_child_path_cannot_spawn() {
841 let dir = std::env::temp_dir().join("guard-core-child-spawn-refused");
844 std::fs::create_dir_all(&dir).expect("temp dir");
845 let bogus = dir.join("not-executable");
846 std::fs::write(&bogus, b"#!/bin/sh\n").expect("write the data file");
847 let outcome = run_child_request_at(&ChildRequest::ReferenceLoad, 1.0, Some(bogus));
848 let detail = expect_failed(outcome);
849 assert!(detail.contains("spawn failed"), "{detail}");
850 }
851
852 #[test]
853 fn a_failing_child_binary_is_a_spawn_failure() {
854 let outcome = run_child_request_at(
858 &ChildRequest::ReferenceLoad,
859 1.0,
860 Some(PathBuf::from("/usr/bin/false")),
861 );
862 let detail = expect_failed(outcome);
863 assert!(detail.contains("child exited unexpectedly"), "{detail}");
864 }
865
866 #[test]
867 fn flag_prefix_includes_the_ascii_letter() {
868 let flags = Flags {
869 ascii: true,
870 ..Flags::default()
871 };
872 assert_eq!(flag_prefix(&flags), "(?a)");
873 }
874
875 #[test]
876 fn child_test_strings_reports_the_first_slow_string() {
877 let payload = json!({
880 "op": "test_strings",
881 "pattern": "abc",
882 "test_strings": ["xabcx", "yabcy"],
883 "threshold": -1.0,
884 "flags": {},
885 });
886 let output = dispatch_payload(&payload);
887 assert_eq!(output["safe"], json!(false));
888 let reason = output["reason"].as_str().expect("reason");
889 assert_eq!(reason, "Pattern timed out on test string of length 5");
890 }
891
892 #[test]
893 fn sample_probe_takes_more_samples_only_past_the_trigger() {
894 let mut searches = 0usize;
896 let times = sample_probe("p", 5, 0.05, &mut |_text| {
897 searches += 1;
898 0.001
899 });
900 assert_eq!(times, vec![0.001]);
901 assert_eq!(searches, 1);
902 let mut searches = 0usize;
905 let times = sample_probe("p", 4, 0.05, &mut |_text| {
906 searches += 1;
907 0.1
908 });
909 assert_eq!(times, vec![0.1; 4]);
910 assert_eq!(searches, 4);
911 let mut searches = 0usize;
913 let times = sample_probe("p", 5, 0.05, &mut |_text| {
914 searches += 1;
915 0.3
916 });
917 assert_eq!(times, vec![0.3, 0.3]);
918 assert_eq!(searches, 2);
919 }
920
921 #[test]
922 fn child_stray_verify_returns_null_on_unusable_payloads() {
923 let bad_pattern = json!({
924 "op": "stray_verify",
925 "pattern": "[invalid",
926 "flags": {},
927 "cases": [["x", ["x"]]],
928 });
929 assert_eq!(dispatch_payload(&bad_pattern), Value::Null);
930 let no_cases = json!({
931 "op": "stray_verify",
932 "pattern": "abc",
933 "flags": {},
934 });
935 assert_eq!(dispatch_payload(&no_cases), Value::Null);
936 let unknown_op = json!({ "op": "teleport" });
937 assert_eq!(dispatch_payload(&unknown_op), Value::Null);
938 }
939
940 #[test]
941 fn the_child_dispatch_answers_every_op_in_process() {
942 let safe = dispatch_payload(&json!({
944 "op": "test_strings",
945 "pattern": "abc",
946 "test_strings": ["zzz"],
947 "threshold": 1.0,
948 "flags": {},
949 }));
950 assert_eq!(safe["safe"], json!(true));
951 assert_eq!(safe["reason"], json!("Pattern appears safe"));
952 let rejected = dispatch_payload(&json!({
953 "op": "test_strings",
954 "pattern": "[invalid",
955 "test_strings": [],
956 "threshold": 1.0,
957 "flags": {},
958 }));
959 assert_eq!(rejected["safe"], json!(false));
960 assert!(
961 rejected["reason"]
962 .as_str()
963 .expect("reason")
964 .starts_with("Pattern validation failed: "),
965 "{rejected}"
966 );
967
968 let timing = dispatch_payload(&json!({
971 "op": "reach_timing",
972 "pattern": "abc",
973 "probes": ["xabcx", "zzz"],
974 "samples": 2,
975 "deadline": 1.0,
976 "flags": {},
977 "trigger": 0.0,
978 }));
979 let results = timing["results"].as_array().expect("result rows");
980 assert_eq!(results.len(), 2);
981 for row in results {
982 assert!(!row.as_array().expect("samples").is_empty());
983 }
984 let reference = timing["reference"].as_f64().expect("reference");
985 assert!(reference >= 0.0, "{reference}");
986 let timing_rejected = dispatch_payload(&json!({
987 "op": "reach_timing",
988 "pattern": "[invalid",
989 "probes": ["x"],
990 "samples": 1,
991 "deadline": 1.0,
992 "flags": {},
993 "trigger": 0.0,
994 }));
995 assert!(timing_rejected.get("error").is_some(), "{timing_rejected}");
996
997 let load = dispatch_payload(&json!({ "op": "reference_load" }));
999 let reference = load["reference"].as_f64().expect("reference");
1000 assert!(reference > 0.0, "{reference}");
1001
1002 let stray = dispatch_payload(&json!({
1005 "op": "stray_verify",
1006 "pattern": "^a+$",
1007 "flags": {},
1008 "cases": [["\u{0}", ["a\u{0}b", "b"]], ["z", ["zz"]]],
1009 }));
1010 assert_eq!(stray, json!("\u{0}"));
1011 let no_stray = dispatch_payload(&json!({
1012 "op": "stray_verify",
1013 "pattern": "^a+$",
1014 "flags": {},
1015 "cases": [["a", ["aaa"]]],
1016 }));
1017 assert_eq!(no_stray, Value::Null);
1018 let fancy_stray = dispatch_payload(&json!({
1021 "op": "stray_verify",
1022 "pattern": "(?!x)a+$",
1023 "flags": {},
1024 "cases": [["\u{0}", ["a\u{0}"]]],
1025 }));
1026 assert_eq!(fancy_stray, json!("\u{0}"));
1027 }
1028
1029 #[test]
1030 fn search_misses_reports_fancy_results() {
1031 let compiled = CompiledProbe::compile("(?!x)a", &Flags::default()).expect("fancy compile");
1034 let regex = expect_fancy(&compiled);
1035 assert!(regex.is_match("ab").expect("search"), "sanity: it matches");
1036 assert!(!search_misses(&compiled, "ab"), "a match is not a miss");
1037 assert!(search_misses(&compiled, "bbb"), "no match is a miss");
1038 }
1039
1040 #[test]
1041 fn child_main_ignores_foreign_invocations() {
1042 assert_eq!(child_main(&[]), None);
1043 assert_eq!(child_main(&["prog".to_owned()]), None);
1044 assert_eq!(child_main(&["prog".to_owned(), "other".to_owned()]), None);
1045 }
1046
1047 #[test]
1048 fn the_probe_child_fails_on_unreadable_stdin() {
1049 let status = Command::new(child_path().expect("child binary"))
1052 .arg(SUBCOMMAND)
1053 .stdin(std::fs::File::open("/").expect("directory handle"))
1054 .stdout(Stdio::piped())
1055 .stderr(Stdio::piped())
1056 .status()
1057 .expect("child status");
1058 assert_eq!(status.code(), Some(1));
1059 }
1060
1061 #[test]
1062 fn the_probe_child_fails_on_a_malformed_payload() {
1063 let mut child = Command::new(child_path().expect("child binary"))
1064 .arg(SUBCOMMAND)
1065 .stdin(Stdio::piped())
1066 .stdout(Stdio::piped())
1067 .stderr(Stdio::piped())
1068 .spawn()
1069 .expect("spawn");
1070 {
1071 let mut stdin = child.stdin.take().expect("piped stdin");
1072 let _ = stdin.write_all(b"not json");
1073 }
1074 let status = child.wait().expect("child status");
1075 assert_eq!(status.code(), Some(1));
1076 }
1077
1078 #[test]
1079 #[should_panic(expected = "expected an exit failure")]
1080 fn expect_failed_rejects_other_outcomes() {
1081 let _ = expect_failed(Err(ChildSpawnError::Timeout));
1082 }
1083
1084 #[test]
1085 #[should_panic(expected = "expected the fancy engine")]
1086 fn expect_fancy_rejects_the_regex_engine() {
1087 let compiled = CompiledProbe::compile("abc", &Flags::default()).expect("compile");
1088 let _ = expect_fancy(&compiled);
1089 }
1090
1091 #[test]
1092 fn child_compilation_covers_the_parser_surface() {
1093 for pattern in [
1097 r"[a-z]+",
1098 r"(?i)(?:\d{2,4}|\w+-\w+)+$",
1099 r"(?P<name>x)(?P=name)",
1100 r"(a|b|c)*d",
1101 r"[\x41-\x5a]+\u00e9",
1102 r"a{2,5}b{2,}",
1103 r"(?=look)a+less",
1104 r"\b(?:one|two)\s+three\b",
1105 ] {
1106 let request = ChildRequest::TestStrings {
1107 pattern: pattern.to_owned(),
1108 test_strings: vec!["probe".to_owned()],
1109 threshold: 1.0,
1110 flags: Flags::default(),
1111 };
1112 let outcome = run_child_request(&request, 2.0);
1113 assert!(outcome.is_ok(), "{pattern}: {outcome:?}");
1114 }
1115 let request = ChildRequest::TestStrings {
1117 pattern: "[invalid".to_owned(),
1118 test_strings: vec![],
1119 threshold: 1.0,
1120 flags: Flags::default(),
1121 };
1122 let outcome = run_child_request(&request, 2.0).expect("rejected payload");
1123 assert_eq!(
1124 outcome,
1125 ChildOutcome::Safety {
1126 safe: false,
1127 reason: "Pattern validation failed: Parsing error at position 8: \
1128 Invalid character class"
1129 .to_owned(),
1130 }
1131 );
1132 }
1133}