Skip to main content

guard_core_engine/patterns/
binary.rs

1//! Binary artifact density gate and binary-content heuristic, ported from
2//! `guard_core/detection_engine/binary_prefix.py` and `semantic.py`
3//! (`looks_like_binary_content`).
4//!
5//! String-model mapping: the reference scans decoded text where undecodable
6//! bytes surface as surrogateescape code points (U+DC80..U+DCFF) and mojibake
7//! as U+FFFD; both classes count as artifacts. The engine scans `&str` (always
8//! valid UTF-8), so undecodable bytes never reach this module as surrogates;
9//! the port's decode boundary represents them as U+FFFD (or drops them), which
10//! is an artifact class here. The surrogate branch is kept for parity with the
11//! reference class definition but is unreachable for `char`. All window
12//! arithmetic runs on code-point indices, the same index space the corpus
13//! mandates for threat positions.
14
15/// Cumulative artifact-count prefix: `counts[i]` = artifacts in `s[..i]`.
16/// Built over code points; indexing is by code-point index (the same space
17/// the structural matchers and threat positions use).
18#[must_use]
19pub fn build_binary_prefix(text: &str) -> Vec<u32> {
20    let mut counts = Vec::with_capacity(text.chars().count() + 1);
21    counts.push(0u32);
22    let mut count = 0u32;
23    for c in text.chars() {
24        if is_binary_artifact(c) {
25            count += 1;
26        }
27        counts.push(count);
28    }
29    counts
30}
31
32const BINARY_DENSITY_RADIUS: usize = 64;
33const BINARY_DENSITY_LIMIT: u32 = 4;
34
35#[must_use]
36pub fn match_is_binary_density(
37    binary_prefix: &[u32],
38    match_start: usize,
39    match_end: usize,
40) -> bool {
41    if binary_prefix.is_empty() {
42        return false;
43    }
44    let high = (match_end + BINARY_DENSITY_RADIUS).min(binary_prefix.len() - 1);
45    let low = match_start.saturating_sub(BINARY_DENSITY_RADIUS);
46    binary_prefix[high].saturating_sub(binary_prefix[low]) >= BINARY_DENSITY_LIMIT
47}
48
49#[must_use]
50fn is_binary_artifact(c: char) -> bool {
51    let cp = c as u32;
52    // control characters (except tab/LF/CR) and DEL
53    if (cp < 0x20 && !matches!(cp, 0x09 | 0x0A | 0x0D)) || cp == 0x7F {
54        return true;
55    }
56    // replacement character
57    if cp == 0xFFFD {
58        return true;
59    }
60    // surrogateescape byte range (raw undecodable bytes)
61    if (0xDC80..=0xDCFF).contains(&cp) {
62        return true;
63    }
64    // Latin-1/Latin-Ext-A artifact, minus the text allowlist
65    if (0x80..=0x024F).contains(&cp) {
66        return !is_text_allowlist(c);
67    }
68    false
69}
70
71/// The only characters in `0x80..=0x024F` treated as text. Mirrors the
72/// reference class complement exactly: within `0x80..=0xBF` only the
73/// punctuation/marker singles are text, and all of `0xC0..=0xFF` (including
74/// the multiplication and division signs) plus `0x100..=0x17F` are text.
75#[must_use]
76fn is_text_allowlist(c: char) -> bool {
77    let cp = c as u32;
78    if (0x00C0..=0x00FF).contains(&cp) {
79        return true;
80    }
81    if matches!(
82        cp,
83        0x00A3 | 0x00A5 | 0x00AA | 0x00B0 | 0x00B1 | 0x00B2 | 0x00B3 | 0x00B5 | 0x00B9 | 0x00BA
84    ) {
85        return true;
86    }
87    (0x0100..=0x017F).contains(&cp)
88}
89
90const BINARY_CONTENT_RATIO_THRESHOLD: f64 = 0.2;
91
92/// `looks_like_binary_content` from semantic.py.
93#[must_use]
94pub fn looks_like_binary_content(content: &str) -> bool {
95    if content.is_empty() {
96        return false;
97    }
98    let mut non_text = 0usize;
99    let mut len = 0usize;
100    for c in content.chars() {
101        len += 1;
102        let cp = c as u32;
103        let is_ws = matches!(c, '\t' | '\r' | '\n');
104        let printable = !c.is_control() && !matches!(cp, 0xFFFD);
105        if !is_ws && (!printable || cp == 0xFFFD) {
106            non_text += 1;
107        }
108    }
109    let ratio = non_text as f64 / len as f64;
110    ratio >= BINARY_CONTENT_RATIO_THRESHOLD
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn pure_text_has_zero_artifacts() {
119        let prefix = build_binary_prefix("hello <script> world\t\n");
120        assert_eq!(prefix.last(), Some(&0));
121    }
122
123    #[test]
124    fn control_bytes_count() {
125        let prefix = build_binary_prefix("a\u{1}b\u{2}c\u{3}d\u{4}");
126        assert!(match_is_binary_density(&prefix, 0, 8));
127    }
128
129    #[test]
130    fn accented_text_is_allowed() {
131        let prefix = build_binary_prefix("caf\u{00e9} cr\u{00e8}me br\u{00fb}l\u{00e9}");
132        assert_eq!(prefix.last(), Some(&0));
133    }
134
135    #[test]
136    fn artifact_classes_match_reference_exactly() {
137        // The reference `_BINARY_ARTIFACT_RE` class set, collapsed to ranges
138        // over the BMP ranges it covers (verified against guard-core 4.0.3).
139        // Everything below 0x300 outside these ranges is text.
140        const ARTIFACT_RANGES: &[(u32, u32)] = &[
141            (0x00, 0x08),
142            (0x0B, 0x0C),
143            (0x0E, 0x1F),
144            (0x7F, 0xA2),
145            (0xA4, 0xA4),
146            (0xA6, 0xA9),
147            (0xAB, 0xAF),
148            (0xB4, 0xB4),
149            (0xB6, 0xB8),
150            (0xBB, 0xBF),
151            (0x0180, 0x024F),
152        ];
153        let is_ref_artifact = |cp: u32| {
154            ARTIFACT_RANGES
155                .iter()
156                .any(|(lo, hi)| (*lo..=*hi).contains(&cp))
157        };
158        for cp in 0u32..=0x3000 {
159            let c = char::from_u32(cp).expect("BMP scalar");
160            assert_eq!(
161                is_binary_artifact(c),
162                is_ref_artifact(cp),
163                "artifact mismatch at U+{cp:04X}"
164            );
165        }
166    }
167
168    #[test]
169    fn latin1_punctuation_signs_are_text() {
170        // U+00D7 (multiplication sign) and U+00F7 (division sign) sit in the
171        // reference's text allowlist (all of 0xC0..=0xFF is text).
172        let prefix = build_binary_prefix("3 \u{00d7} 4 \u{00f7} 2");
173        assert_eq!(prefix.last(), Some(&0));
174    }
175}