Skip to main content

guard_core_engine/redos/
child.rs

1//! Killable probe subprocesses.
2//!
3//! The reference times untrusted patterns in `python -c` children so a
4//! runaway loop can be killed; this port self-execs this crate's
5//! `guard-pattern-probe` binary with a hidden subcommand and speaks JSON
6//! on stdin/stdout. A runaway `fancy-regex` backtracking loop cannot be
7//! interrupted in-process, so every timing probe runs in a child we can
8//! kill on deadline.
9//!
10//! Parent side: [`run_child_request`] spawns and enforces the deadline.
11//! Child side: [`child_main`] is wired into the binary target
12//! `guard-pattern-probe`.
13
14use 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
24/// Hidden subcommand name the child dispatches on.
25pub const SUBCOMMAND: &str = "__guard_pattern_probe";
26
27/// The large-sample early-exit threshold (reference
28/// `_REACH_PROBE_LARGE_SAMPLE_SECONDS`).
29pub 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/// What the parent asks the child to do.
36#[derive(Debug, Clone)]
37pub enum ChildRequest {
38    /// The pattern-safety probe: search every test string under a
39    /// per-string threshold.
40    TestStrings {
41        pattern: String,
42        test_strings: Vec<String>,
43        threshold: f64,
44        flags: Flags,
45    },
46    /// The reach-probe timing ladder.
47    ReachTiming {
48        pattern: String,
49        probes: Vec<String>,
50        samples: usize,
51        deadline: f64,
52        flags: Flags,
53        trigger: f64,
54    },
55    /// The reference scan, for host-load normalization.
56    ReferenceLoad,
57    /// Stray verification: the first candidate every probe fails on.
58    StrayVerify {
59        pattern: String,
60        flags: Flags,
61        cases: Vec<(String, Vec<String>)>,
62    },
63}
64
65/// Decoded child output.
66#[derive(Debug, Clone, PartialEq)]
67pub enum ChildOutcome {
68    /// `{"safe": bool, "reason": str}` from the pattern-safety probe.
69    Safety { safe: bool, reason: String },
70    /// `{"results": [[...]], "reference": float}` from reach timing.
71    Timing {
72        results: Vec<Vec<f64>>,
73        reference: f64,
74    },
75    /// `{"reference": float}` from the load probe.
76    Reference { reference: f64 },
77    /// The chosen stray candidate (or none).
78    Stray(Option<String>),
79    /// `{"error": str}` from a reach-timing compile failure.
80    Failed(String),
81}
82
83/// Spawn failures the parent maps to reference exception text.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ChildSpawnError {
86    /// The deadline elapsed; the child was killed.
87    Timeout,
88    /// The child could not be spawned or exited unexpectedly.
89    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/// Locate the probe child binary.
221#[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/// The pure half of [`child_path`], testable without process state.
231#[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))] // unreachable: the only parentless unix path is the
248    // filesystem root, whose `file_name` is `None` and returned two lines up
249    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        // Unit tests run from target/debug/deps; the sibling binary is in
257        // target/debug.
258        for name in ["guard-pattern-probe", "guard-pattern-probe.exe"] {
259            candidates.push(base.join(name));
260        }
261    }
262    // Doctests run from a temp directory, so the executable-relative search
263    // cannot find the probe binary. The workspace-relative location is
264    // compile-time known (crates/guard-core-engine -> <workspace>/target),
265    // so the option always yields a workspace under cargo.
266    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
280/// Run a child request under `timeout_secs`, killing the child on the
281/// deadline.
282///
283/// # Errors
284///
285/// [`ChildSpawnError`] on deadline or spawn/exit failure.
286pub 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
293/// The pure half of [`run_child_request`]: the child binary path is
294/// injected so tests can force spawn and exit failures deterministically.
295///
296/// # Errors
297///
298/// [`ChildSpawnError`] on deadline or spawn/exit failure.
299pub(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    // `try_wait` could only fail with ECHILD here, which cannot happen:
334    // the child was spawned by this process above and only this loop reaps
335    // it. The `?` keeps that impossible path on an always-evaluated line.
336    let status = loop {
337        #[cfg(not(coverage))] // unreachable: `try_wait` fails only with
338        // ECHILD, impossible for a child this process spawned above and
339        // alone reaps in this loop (see the comment before the loop)
340        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
391/// The compiled engine the child times with: `regex` where the pattern
392/// compiles, `fancy-regex` for fancy-only constructs.
393pub enum CompiledProbe {
394    Re(regex::Regex),
395    Fancy(fancy_regex::Regex),
396}
397
398impl CompiledProbe {
399    /// Compile with the crate the engine will actually execute.
400    ///
401    /// # Errors
402    ///
403    /// The first engine's error text when both fail.
404    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    /// One search pass, returning wall time.
415    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))] // unreachable: REFERENCE_SCAN_PATTERN is a
431    // compile-time constant that compiles under the regex engine
432    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
505/// Reference sampling: one first pass, then more only while the first met
506/// the trigger, stopping early on a large sample.
507fn 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
560/// Child entry: dispatch the hidden subcommand. Returns the process exit
561/// code; `None` when `args` is not the probe subcommand.
562pub 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/// The pure child dispatch, testable without stdin.
582#[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    /// Extract the unexpected-exit detail; panics on any other outcome.
599    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    /// Extract the fancy engine; panics on the `regex` engine variant.
607    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        // Under cargo test the sibling binary sits in target/debug.
617        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        // Lookarounds only compile under fancy-regex.
684        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        // A fancy-regex backtracking pattern over a 32000-char probe is
700        // catastrophically slow on every host, so the 50ms deadline is
701        // guaranteed to fire mid-run: the parent polls, kills, waits, and
702        // reports a timeout instead of blocking forever on a child that
703        // cannot be interrupted in-process. (A sub-millisecond deadline on
704        // ReferenceLoad raced the child's completion on fast runners.)
705        let pattern = "(?!x)(?:a|aa)+$";
706        // The trailing b forces the match to fail after the greedy run,
707        // sending the backtracker through the exponential split space.
708        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        // the kill must be prompt: the deadline poll loop never blocks long
721        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    /// Extract the `regex` engine; panics on the fancy engine.
738    fn expect_re(compiled: &CompiledProbe) -> &regex::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        // The filesystem root has no file name component.
788        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        // A missing override cannot resolve on its own; the candidate
816        // search then walks the exe siblings and finally the workspace
817        // target dir, where cargo built the probe binary.
818        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        // A plain data file lacks the executable bit, so the OS refuses the
842        // spawn with EACCES no matter where the test runs.
843        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        // /usr/bin/false exists on every supported host and exits nonzero
855        // with no output, which the parent maps to an unexpected-exit
856        // failure.
857        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        // A negative threshold makes every search deterministically exceed
878        // it, so the slow-string payload is produced without wall-clock luck.
879        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        // First sample below the trigger: exactly one sample.
895        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        // First sample past the trigger but under the large-sample bound:
903        // the full sample ladder runs.
904        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        // A sample over the large-sample bound stops the ladder early.
912        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        // test_strings: the safe answer and the compile-failure answer.
943        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        // reach_timing: real sample rows for a fast pattern, plus the
969        // compile-failure payload.
970        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        // reference_load: a positive normalized scan time.
998        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        // stray_verify: the first all-miss candidate wins; an
1003        // all-matching case list answers null.
1004        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        // A lookaround pattern routes the same body through the fancy
1019        // engine's miss check.
1020        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        // Lookarounds force the fancy engine; the helper must report both
1032        // matched and unmatched probes correctly for it.
1033        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        // Reading a directory fd fails with EISDIR on macOS and Linux, so
1050        // the child exits 1 before parsing any payload.
1051        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        // Each request makes the child compile (or reject) a different
1094        // pattern shape, so the child's own engine instance exercises the
1095        // same breadth the in-process suite does.
1096        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        // A rejected pattern drives the child's compile-failure payload.
1116        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}