Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/inspect/src/yara_lite.rs

//! **A built-in matcher for the plain subset of the YARA rule language.**
//!
//! Most signature rules an air-gapped site receives are string rules: text and
//! hex strings and a boolean condition over them. This module runs exactly that
//! subset in process, so the YARA adapter works with no engine installed and
//! can scan every member of an archive without spawning a process per member.
//!
//! Supported:
//! * `rule NAME [: tags] { meta: … strings: … condition: … }`, with the
//!   `private` and `global` modifiers accepted and ignored;
//! * text strings with `\" \\ \n \r \t \xHH` escapes and the `nocase`, `ascii`,
//!   `wide` and `fullword` modifiers;
//! * hex strings of byte pairs and `??` wildcards;
//! * conditions built from `$id`, `true`, `false`, `not`, `and`, `or`,
//!   parentheses, and `any|all|N of them` / `of ($a, $b*)`.
//!
//! Anything else (imports, regular expressions, jumps, alternatives, counts,
//! offsets, `filesize`, modules) is refused by NAME with [`Unsupported`]. The
//! adapter then needs the real `yara` binary, and without it reports
//! "not configured" rather than silently matching less than the rule says.

use std::fmt;

/// The rules use something outside the subset, or do not parse.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unsupported(pub String);

impl fmt::Display for Unsupported {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

fn bail<T>(msg: impl Into<String>) -> Result<T, Unsupported> {
    Err(Unsupported(msg.into()))
}

#[derive(Debug, Clone)]
struct Pattern {
    /// `None` is a `??` wildcard.
    bytes: Vec<Option<u8>>,
    nocase: bool,
    fullword: bool,
}

#[derive(Debug, Clone)]
struct StringDef {
    id: String,
    /// Any of these matching is the string matching (ascii and wide forms).
    forms: Vec<Pattern>,
}

#[derive(Debug, Clone)]
enum Expr {
    Bool(bool),
    Var(String),
    Not(Box<Expr>),
    And(Box<Expr>, Box<Expr>),
    Or(Box<Expr>, Box<Expr>),
    /// `None` = all; the patterns are `$id` or `$prefix*`, empty = them.
    Of(Option<usize>, Vec<String>),
}

#[derive(Debug, Clone)]
struct Rule {
    name: String,
    strings: Vec<StringDef>,
    condition: Expr,
}

/// A compiled rule set.
#[derive(Debug, Clone)]
pub struct Rules {
    rules: Vec<Rule>,
    prefilter: Prefilter,
}

/// Where one pattern form lives: `rules[rule].strings[def].forms[form]`.
#[derive(Debug, Clone, Copy)]
struct FormAt {
    rule: usize,
    def: usize,
    form: usize,
}

/// The literal-atom prefilter (lane T10). Each pattern form's longest run of
/// fixed bytes is its atom; one Aho-Corasick pass per case mode finds every
/// place an atom occurs, and only there is the whole form checked. A form with
/// no fixed byte at all (`{ ?? ?? }`) is tried at every offset, as before.
///
/// Every occurrence of the atom is reported (overlapping search), and a form can
/// only match where its atom does, so the prefilter tests a subset of the
/// offsets the plain scan tests that contains every offset that can match: the
/// answers are the plain scan's.
#[derive(Debug, Clone)]
struct Prefilter {
    forms: Vec<FormAt>,
    /// Case-sensitive atoms, and per atom `(form index, atom offset in the form)`.
    exact: Option<(aho_corasick::AhoCorasick, Vec<(usize, usize)>)>,
    /// ASCII case-insensitive atoms (`nocase` forms).
    nocase: Option<(aho_corasick::AhoCorasick, Vec<(usize, usize)>)>,
    /// Forms with no fixed byte.
    unanchored: Vec<usize>,
}

/// The longest run of fixed bytes in `p`: `(offset, bytes)`.
fn atom(p: &Pattern) -> Option<(usize, Vec<u8>)> {
    let (mut best, mut cur_start) = (None::<(usize, usize)>, None::<usize>);
    for i in 0..=p.bytes.len() {
        match p.bytes.get(i).copied().flatten() {
            Some(_) => {
                cur_start.get_or_insert(i);
            }
            None => {
                if let Some(st) = cur_start.take() {
                    if best.is_none_or(|(_, len)| i - st > len) {
                        best = Some((st, i - st));
                    }
                }
            }
        }
    }
    best.map(|(st, len)| {
        (
            st,
            p.bytes[st..st + len].iter().map(|b| b.unwrap()).collect(),
        )
    })
}

impl Prefilter {
    fn build(rules: &[Rule]) -> Result<Prefilter, Unsupported> {
        let mut forms = Vec::new();
        let (mut ex_atoms, mut ex_ids, mut nc_atoms, mut nc_ids, mut unanchored) =
            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
        for (ri, r) in rules.iter().enumerate() {
            for (di, d) in r.strings.iter().enumerate() {
                for (fi, f) in d.forms.iter().enumerate() {
                    let id = forms.len();
                    forms.push(FormAt {
                        rule: ri,
                        def: di,
                        form: fi,
                    });
                    match atom(f) {
                        None => unanchored.push(id),
                        Some((off, bytes)) if f.nocase => {
                            nc_atoms.push(bytes);
                            nc_ids.push((id, off));
                        }
                        Some((off, bytes)) => {
                            ex_atoms.push(bytes);
                            ex_ids.push((id, off));
                        }
                    }
                }
            }
        }
        let build = |atoms: Vec<Vec<u8>>, ids: Vec<(usize, usize)>, nocase: bool| {
            if atoms.is_empty() {
                return Ok(None);
            }
            aho_corasick::AhoCorasick::builder()
                .ascii_case_insensitive(nocase)
                .build(&atoms)
                .map(|ac| Some((ac, ids)))
                .map_err(|e| Unsupported(format!("the literal prefilter: {e}")))
        };
        Ok(Prefilter {
            forms,
            exact: build(ex_atoms, ex_ids, false)?,
            nocase: build(nc_atoms, nc_ids, true)?,
            unanchored,
        })
    }

    /// Which forms occur in `data`, indexed like `self.forms`.
    fn found(&self, rules: &[Rule], data: &[u8]) -> Vec<bool> {
        let form = |id: usize| {
            let at = self.forms[id];
            &rules[at.rule].strings[at.def].forms[at.form]
        };
        let mut found = vec![false; self.forms.len()];
        let mut left = self.forms.len() - self.unanchored.len();
        for (ac, ids) in [&self.exact, &self.nocase].into_iter().flatten() {
            if left == 0 {
                break;
            }
            for m in ac.find_overlapping_iter(data) {
                let (id, off) = ids[m.pattern().as_usize()];
                if found[id] || m.start() < off {
                    continue;
                }
                if form(id).matches_at(data, m.start() - off) {
                    found[id] = true;
                    left -= 1;
                    if left == 0 {
                        break;
                    }
                }
            }
        }
        for &id in &self.unanchored {
            found[id] = form(id).found_in(data);
        }
        found
    }
}

// ── lexer ───────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, PartialEq)]
enum Tok {
    Ident(String),
    Var(String),
    Str(Vec<u8>),
    Hex(Vec<Option<u8>>),
    Num(u64),
    Punct(char),
}

struct Lexer<'a> {
    s: &'a [u8],
    i: usize,
    /// The previous token was `=` after a `$var`, so a `{` opens a hex string.
    hex_next: bool,
}

impl<'a> Lexer<'a> {
    fn skip_ws(&mut self) -> Result<(), Unsupported> {
        loop {
            while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
                self.i += 1;
            }
            if self.s[self.i..].starts_with(b"//") {
                while self.i < self.s.len() && self.s[self.i] != b'\n' {
                    self.i += 1;
                }
            } else if self.s[self.i..].starts_with(b"/*") {
                match self.s[self.i + 2..].windows(2).position(|w| w == b"*/") {
                    Some(p) => self.i += p + 4,
                    None => return bail("unterminated comment"),
                }
            } else {
                return Ok(());
            }
        }
    }

    fn next(&mut self) -> Result<Option<Tok>, Unsupported> {
        self.skip_ws()?;
        let Some(&c) = self.s.get(self.i) else {
            return Ok(None);
        };
        let hex_next = std::mem::replace(&mut self.hex_next, false);
        if c == b'{' && hex_next {
            self.i += 1;
            let end = match self.s[self.i..].iter().position(|&b| b == b'}') {
                Some(p) => self.i + p,
                None => return bail("unterminated hex string"),
            };
            let body = std::str::from_utf8(&self.s[self.i..end])
                .map_err(|_| Unsupported("hex string is not text".into()))?;
            self.i = end + 1;
            let mut out = Vec::new();
            for t in body.split_whitespace() {
                if t == "??" {
                    out.push(None);
                } else if t.len() == 2 && t.chars().all(|c| c.is_ascii_hexdigit()) {
                    out.push(Some(u8::from_str_radix(t, 16).unwrap()));
                } else {
                    return bail(format!("hex token {t:?} (jumps, alternatives and nibble wildcards need the yara engine)"));
                }
            }
            if out.is_empty() {
                return bail("empty hex string");
            }
            return Ok(Some(Tok::Hex(out)));
        }
        if c == b'"' {
            self.i += 1;
            let mut out = Vec::new();
            loop {
                let Some(&b) = self.s.get(self.i) else {
                    return bail("unterminated text string");
                };
                self.i += 1;
                match b {
                    b'"' => break,
                    b'\\' => {
                        let Some(&e) = self.s.get(self.i) else {
                            return bail("unterminated escape");
                        };
                        self.i += 1;
                        out.push(match e {
                            b'"' => b'"',
                            b'\\' => b'\\',
                            b'n' => b'\n',
                            b'r' => b'\r',
                            b't' => b'\t',
                            b'x' => {
                                let h = self
                                    .s
                                    .get(self.i..self.i + 2)
                                    .and_then(|h| std::str::from_utf8(h).ok());
                                let Some(v) = h.and_then(|h| u8::from_str_radix(h, 16).ok()) else {
                                    return bail("bad \\x escape");
                                };
                                self.i += 2;
                                v
                            }
                            other => return bail(format!("escape \\{}", other as char)),
                        });
                    }
                    b => out.push(b),
                }
            }
            return Ok(Some(Tok::Str(out)));
        }
        if c == b'/' {
            return bail("regular expressions need the yara engine");
        }
        if c == b'$' || c == b'_' || c.is_ascii_alphabetic() {
            let start = self.i;
            self.i += 1;
            while self.i < self.s.len()
                && (self.s[self.i] == b'_'
                    || self.s[self.i].is_ascii_alphanumeric()
                    || self.s[self.i] == b'*')
            {
                self.i += 1;
            }
            let word = String::from_utf8_lossy(&self.s[start..self.i]).into_owned();
            return Ok(Some(if c == b'$' {
                Tok::Var(word)
            } else {
                Tok::Ident(word)
            }));
        }
        if c.is_ascii_digit() {
            let start = self.i;
            while self.i < self.s.len() && self.s[self.i].is_ascii_digit() {
                self.i += 1;
            }
            let n = std::str::from_utf8(&self.s[start..self.i])
                .unwrap()
                .parse()
                .map_err(|_| Unsupported("number".into()))?;
            return Ok(Some(Tok::Num(n)));
        }
        if b"{}():=,".contains(&c) {
            self.i += 1;
            return Ok(Some(Tok::Punct(c as char)));
        }
        bail(format!("unexpected character {:?}", c as char))
    }
}

fn lex(src: &str) -> Result<Vec<Tok>, Unsupported> {
    let mut lx = Lexer {
        s: src.as_bytes(),
        i: 0,
        hex_next: false,
    };
    let mut out: Vec<Tok> = Vec::new();
    while let Some(t) = lx.next()? {
        // `$id =` followed by `{` is a hex string, not a block.
        if t == Tok::Punct('=') && matches!(out.last(), Some(Tok::Var(_))) {
            lx.hex_next = true;
        }
        out.push(t);
    }
    Ok(out)
}

// ── parser ──────────────────────────────────────────────────────────────────

struct Parser {
    t: Vec<Tok>,
    i: usize,
}

impl Parser {
    fn peek(&self) -> Option<&Tok> {
        self.t.get(self.i)
    }
    fn bump(&mut self) -> Option<Tok> {
        let t = self.t.get(self.i).cloned();
        self.i += 1;
        t
    }
    fn is_ident(&self, w: &str) -> bool {
        matches!(self.peek(), Some(Tok::Ident(x)) if x == w)
    }
    fn expect_punct(&mut self, c: char) -> Result<(), Unsupported> {
        match self.bump() {
            Some(Tok::Punct(p)) if p == c => Ok(()),
            other => bail(format!("expected '{c}', found {other:?}")),
        }
    }
    fn expect_ident(&mut self) -> Result<String, Unsupported> {
        match self.bump() {
            Some(Tok::Ident(w)) => Ok(w),
            other => bail(format!("expected a name, found {other:?}")),
        }
    }

    fn rule(&mut self) -> Result<Rule, Unsupported> {
        while self.is_ident("private") || self.is_ident("global") {
            self.bump();
        }
        if self.is_ident("import") || self.is_ident("include") {
            return bail("imports and includes need the yara engine");
        }
        if self.expect_ident()? != "rule" {
            return bail("expected 'rule'");
        }
        let name = self.expect_ident()?;
        if self.peek() == Some(&Tok::Punct(':')) {
            self.bump();
            while matches!(self.peek(), Some(Tok::Ident(_))) {
                self.bump();
            }
        }
        self.expect_punct('{')?;
        let mut strings = Vec::new();
        let mut condition = None;
        loop {
            match self.bump() {
                Some(Tok::Punct('}')) => break,
                Some(Tok::Ident(sec)) if sec == "meta" => {
                    self.expect_punct(':')?;
                    while matches!(self.peek(), Some(Tok::Ident(w)) if !["strings", "condition"].contains(&w.as_str()))
                    {
                        self.bump();
                        self.expect_punct('=')?;
                        match self.bump() {
                            Some(Tok::Str(_)) | Some(Tok::Num(_)) | Some(Tok::Ident(_)) => {}
                            other => return bail(format!("meta value {other:?}")),
                        }
                    }
                }
                Some(Tok::Ident(sec)) if sec == "strings" => {
                    self.expect_punct(':')?;
                    while let Some(Tok::Var(id)) = self.peek().cloned() {
                        self.bump();
                        self.expect_punct('=')?;
                        strings.push(self.string_def(id)?);
                    }
                }
                Some(Tok::Ident(sec)) if sec == "condition" => {
                    self.expect_punct(':')?;
                    condition = Some(self.or()?);
                }
                other => return bail(format!("in rule {name}: unexpected {other:?}")),
            }
        }
        let condition =
            condition.ok_or_else(|| Unsupported(format!("rule {name} has no condition")))?;
        Ok(Rule {
            name,
            strings,
            condition,
        })
    }

    fn string_def(&mut self, id: String) -> Result<StringDef, Unsupported> {
        match self.bump() {
            Some(Tok::Hex(bytes)) => Ok(StringDef {
                id,
                forms: vec![Pattern {
                    bytes,
                    nocase: false,
                    fullword: false,
                }],
            }),
            Some(Tok::Str(text)) => {
                let (mut nocase, mut ascii, mut wide, mut fullword) = (false, false, false, false);
                while let Some(Tok::Ident(m)) = self.peek().cloned() {
                    match m.as_str() {
                        "nocase" => nocase = true,
                        "ascii" => ascii = true,
                        "wide" => wide = true,
                        "fullword" => fullword = true,
                        "private" => {}
                        _ => break,
                    }
                    self.bump();
                }
                if text.is_empty() {
                    return bail(format!("{id} is empty"));
                }
                let mut forms = Vec::new();
                if ascii || !wide {
                    forms.push(Pattern {
                        bytes: text.iter().map(|b| Some(*b)).collect(),
                        nocase,
                        fullword,
                    });
                }
                if wide {
                    forms.push(Pattern {
                        bytes: text.iter().flat_map(|b| [Some(*b), Some(0)]).collect(),
                        nocase,
                        fullword,
                    });
                }
                Ok(StringDef { id, forms })
            }
            other => bail(format!(
                "{id}: regular expressions and {other:?} need the yara engine"
            )),
        }
    }

    fn or(&mut self) -> Result<Expr, Unsupported> {
        let mut e = self.and()?;
        while self.is_ident("or") {
            self.bump();
            e = Expr::Or(Box::new(e), Box::new(self.and()?));
        }
        Ok(e)
    }
    fn and(&mut self) -> Result<Expr, Unsupported> {
        let mut e = self.not()?;
        while self.is_ident("and") {
            self.bump();
            e = Expr::And(Box::new(e), Box::new(self.not()?));
        }
        Ok(e)
    }
    fn not(&mut self) -> Result<Expr, Unsupported> {
        if self.is_ident("not") {
            self.bump();
            return Ok(Expr::Not(Box::new(self.not()?)));
        }
        self.primary()
    }
    fn primary(&mut self) -> Result<Expr, Unsupported> {
        match self.bump() {
            Some(Tok::Punct('(')) => {
                let e = self.or()?;
                self.expect_punct(')')?;
                Ok(e)
            }
            Some(Tok::Var(v)) => Ok(Expr::Var(v)),
            Some(Tok::Ident(w)) if w == "true" => Ok(Expr::Bool(true)),
            Some(Tok::Ident(w)) if w == "false" => Ok(Expr::Bool(false)),
            Some(Tok::Ident(w)) if w == "any" || w == "all" => {
                self.of(if w == "any" { Some(1) } else { None })
            }
            Some(Tok::Num(n)) => self.of(Some(n as usize)),
            other => bail(format!("condition {other:?} needs the yara engine")),
        }
    }
    fn of(&mut self, n: Option<usize>) -> Result<Expr, Unsupported> {
        if !self.is_ident("of") {
            return bail("counts and offsets need the yara engine");
        }
        self.bump();
        if self.is_ident("them") {
            self.bump();
            return Ok(Expr::Of(n, Vec::new()));
        }
        self.expect_punct('(')?;
        let mut pats = Vec::new();
        loop {
            match self.bump() {
                Some(Tok::Var(v)) => pats.push(v),
                other => return bail(format!("in 'of (...)': {other:?}")),
            }
            match self.bump() {
                Some(Tok::Punct(',')) => continue,
                Some(Tok::Punct(')')) => break,
                other => return bail(format!("in 'of (...)': {other:?}")),
            }
        }
        Ok(Expr::Of(n, pats))
    }
}

// ── matching ────────────────────────────────────────────────────────────────

fn word_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}

impl Pattern {
    /// Does the whole pattern (and its `fullword` rule) match at `at`?
    fn matches_at(&self, hay: &[u8], at: usize) -> bool {
        let n = self.bytes.len();
        if n == 0 || at + n > hay.len() {
            return false;
        }
        let eq = |p: Option<u8>, h: u8| match p {
            None => true,
            Some(p) if self.nocase => p.eq_ignore_ascii_case(&h),
            Some(p) => p == h,
        };
        self.bytes
            .iter()
            .zip(&hay[at..at + n])
            .all(|(p, h)| eq(*p, *h))
            && (!self.fullword
                || ((at == 0 || !word_byte(hay[at - 1]))
                    && (at + n == hay.len() || !word_byte(hay[at + n]))))
    }

    /// The plain scan: the pattern tried at every offset.
    fn found_in(&self, hay: &[u8]) -> bool {
        let n = self.bytes.len();
        if n == 0 || hay.len() < n {
            return false;
        }
        let eq = |p: Option<u8>, h: u8| match p {
            None => true,
            Some(p) if self.nocase => p.eq_ignore_ascii_case(&h),
            Some(p) => p == h,
        };
        // Anchor on the first fixed byte to keep the naive scan cheap.
        let anchor = self.bytes.iter().position(|b| b.is_some());
        (0..=hay.len() - n).any(|at| {
            if let Some(a) = anchor {
                if !eq(self.bytes[a], hay[at + a]) {
                    return false;
                }
            }
            self.bytes
                .iter()
                .zip(&hay[at..at + n])
                .all(|(p, h)| eq(*p, *h))
                && (!self.fullword
                    || ((at == 0 || !word_byte(hay[at - 1]))
                        && (at + n == hay.len() || !word_byte(hay[at + n]))))
        })
    }
}

fn var_matches(pat: &str, id: &str) -> bool {
    match pat.strip_suffix('*') {
        Some(prefix) => id.starts_with(prefix),
        None => pat == id,
    }
}

impl Expr {
    fn eval(&self, defs: &[StringDef], hit: &[bool]) -> bool {
        match self {
            Expr::Bool(b) => *b,
            Expr::Var(v) => defs.iter().zip(hit).any(|(d, h)| d.id == *v && *h),
            Expr::Not(e) => !e.eval(defs, hit),
            Expr::And(a, b) => a.eval(defs, hit) && b.eval(defs, hit),
            Expr::Or(a, b) => a.eval(defs, hit) || b.eval(defs, hit),
            Expr::Of(n, pats) => {
                let chosen: Vec<bool> = defs
                    .iter()
                    .zip(hit)
                    .filter(|(d, _)| pats.is_empty() || pats.iter().any(|p| var_matches(p, &d.id)))
                    .map(|(_, h)| *h)
                    .collect();
                let got = chosen.iter().filter(|h| **h).count();
                match n {
                    None => !chosen.is_empty() && got == chosen.len(),
                    Some(n) => got >= *n,
                }
            }
        }
    }

    fn vars(&self, out: &mut Vec<String>) {
        match self {
            Expr::Var(v) => out.push(v.clone()),
            Expr::Not(e) => e.vars(out),
            Expr::And(a, b) | Expr::Or(a, b) => {
                a.vars(out);
                b.vars(out);
            }
            Expr::Of(_, p) => out.extend(p.iter().cloned()),
            Expr::Bool(_) => {}
        }
    }
}

impl Rules {
    /// Compile rule source, or say which construct needs the real engine.
    pub fn parse(src: &str) -> Result<Rules, Unsupported> {
        let mut p = Parser { t: lex(src)?, i: 0 };
        let mut rules = Vec::new();
        while p.peek().is_some() {
            let r = p.rule()?;
            let mut used = Vec::new();
            r.condition.vars(&mut used);
            for v in used {
                if !r.strings.iter().any(|d| var_matches(&v, &d.id)) {
                    return bail(format!("rule {}: {v} is not defined", r.name));
                }
            }
            rules.push(r);
        }
        if rules.is_empty() {
            return bail("no rules");
        }
        let prefilter = Prefilter::build(&rules)?;
        Ok(Rules { rules, prefilter })
    }

    pub fn len(&self) -> usize {
        self.rules.len()
    }

    /// `(forms the literal prefilter finds, forms tried at every offset)`.
    pub fn prefiltered_forms(&self) -> (usize, usize) {
        let p = &self.prefilter;
        (p.forms.len() - p.unanchored.len(), p.unanchored.len())
    }

    pub fn is_empty(&self) -> bool {
        self.rules.is_empty()
    }

    /// The names of the rules that match `data`.
    ///
    /// One literal-atom pass finds the candidate offsets for every pattern of
    /// every rule ([`Prefilter`]); the conditions are then evaluated as before.
    pub fn scan(&self, data: &[u8]) -> Vec<String> {
        let found = self.prefilter.found(&self.rules, data);
        let mut next = 0;
        let mut out = Vec::new();
        for r in &self.rules {
            let hit: Vec<bool> = r
                .strings
                .iter()
                .map(|d| {
                    let any = found[next..next + d.forms.len()].iter().any(|h| *h);
                    next += d.forms.len();
                    any
                })
                .collect();
            if r.condition.eval(&r.strings, &hit) {
                out.push(r.name.clone());
            }
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn text_and_hex_strings_with_a_condition() {
        let r = Rules::parse(
            r#"
            // a comment
            rule Two : tag1 tag2 {
                meta: author = "holger" score = 5
                strings:
                    $a = "evil" nocase
                    $b = { 4D 5A ?? 00 }
                condition: $a and $b
            }
            rule Any { strings: $x1 = "one" $x2 = "two" condition: any of ($x*) }
            "#,
        )
        .unwrap();
        assert_eq!(r.len(), 2);
        assert_eq!(r.scan(b"MZ\x90\x00 ... EVIL"), vec!["Two".to_string()]);
        assert_eq!(r.scan(b"two"), vec!["Any".to_string()]);
        assert!(r.scan(b"nothing").is_empty());
    }

    #[test]
    fn wide_and_fullword() {
        let r =
            Rules::parse(r#"rule W { strings: $a = "ab" wide fullword condition: all of them }"#)
                .unwrap();
        assert_eq!(r.scan(b"a\0b\0").len(), 1);
        assert!(r.scan(b"ab").is_empty(), "wide only");
        let f = Rules::parse(r#"rule F { strings: $a = "cat" fullword condition: $a }"#).unwrap();
        assert!(f.scan(b"concatenate").is_empty());
        assert_eq!(f.scan(b"a cat.").len(), 1);
    }

    /// The plain scan every rule set must agree with: each pattern tried at every
    /// offset of the data (the matcher before the literal-atom prefilter).
    fn naive_scan(r: &Rules, data: &[u8]) -> Vec<String> {
        r.rules
            .iter()
            .filter(|r| {
                let hit: Vec<bool> = r
                    .strings
                    .iter()
                    .map(|d| d.forms.iter().any(|f| f.found_in(data)))
                    .collect();
                r.condition.eval(&r.strings, &hit)
            })
            .map(|r| r.name.clone())
            .collect()
    }

    const EQUIV_RULES: &str = r#"
        rule Eicar { strings: $a = "EICAR-STANDARD-ANTIVIRUS-TEST-FILE" condition: $a }
        rule NoCase { strings: $a = "discord.com/api/webhooks/" nocase condition: $a }
        rule All3 { strings: $a = "curl " $b = "| sh" $c = "\"preinstall\"" condition: all of them }
        rule Wide { strings: $a = "evil" wide ascii nocase condition: $a }
        rule Full { strings: $a = "cat" fullword $b = "dog" fullword condition: any of them }
        rule Hex { strings: $mz = { 4D 5A 90 00 03 00 00 00 } condition: $mz }
        rule HexWild { strings: $a = { ?? ?? 4D ?? 5A ?? } condition: $a }
        rule AllWild { strings: $a = { ?? ?? ?? } condition: $a }
        rule Lead { strings: $a = { ?? 41 41 41 } condition: $a }
        rule Overlap { strings: $a = "aab" $b = "aaab" condition: $a and $b }
        rule NotIt { strings: $a = "zz" condition: not $a }
        rule Count2 { strings: $x1 = "one" $x2 = "two" $x3 = "three" condition: 2 of ($x*) }
        rule OneByte { strings: $a = "q" fullword condition: $a }
    "#;

    #[test]
    fn the_prefiltered_scan_answers_what_the_plain_scan_answers() {
        let r = Rules::parse(EQUIV_RULES).unwrap();
        let mut inputs: Vec<Vec<u8>> = vec![
            Vec::new(),
            b"x".to_vec(),
            b"AB".to_vec(),
            b"X5O EICAR-STANDARD-ANTIVIRUS-TEST-FILE!".to_vec(),
            b"https://DISCORD.com/API/webhooks/123".to_vec(),
            b"\"preinstall\": \"curl x | sh\"".to_vec(),
            b"curl | sh".to_vec(),
            b"e\0V\0i\0L\0".to_vec(),
            b"EvIl".to_vec(),
            b"concatenate".to_vec(),
            b"a cat.".to_vec(),
            b"cat".to_vec(),
            b"hotdog dog_".to_vec(),
            b"MZ\x90\x00\x03\x00\x00\x00".to_vec(),
            b"xxMZ\x90\x00\x03\x00\x00".to_vec(),
            b"..M.Z.".to_vec(),
            b"M.Z.".to_vec(),
            b"AAAA".to_vec(),
            b"AAA".to_vec(),
            b"aaab".to_vec(),
            b"aab".to_vec(),
            b"zz".to_vec(),
            b"one three".to_vec(),
            b"two".to_vec(),
            b"q".to_vec(),
            b"qq q".to_vec(),
        ];
        // Pseudo-random data over a small alphabet, so atoms overlap and repeat.
        let alphabet = b"aAbBcCqtzM Z\x00\x90.|sh-";
        let mut x: u64 = 0x9e37_79b9_7f4a_7c15;
        for len in [7usize, 31, 257, 4099] {
            for _ in 0..40 {
                let mut v = Vec::with_capacity(len);
                for _ in 0..len {
                    x ^= x << 13;
                    x ^= x >> 7;
                    x ^= x << 17;
                    v.push(alphabet[(x % alphabet.len() as u64) as usize]);
                }
                inputs.push(v);
            }
        }
        let mut matched = 0;
        for data in &inputs {
            let want = naive_scan(&r, data);
            assert_eq!(r.scan(data), want, "{:?}", String::from_utf8_lossy(data));
            matched += want.len();
        }
        assert!(
            matched > 50,
            "the inputs must exercise real matches, got {matched}"
        );
    }

    #[test]
    fn every_pattern_with_a_fixed_byte_runs_through_the_literal_prefilter() {
        let r = Rules::parse(EQUIV_RULES).unwrap();
        // 19 strings, 20 forms (Wide's $a is ascii and wide); only AllWild's
        // `{ ?? ?? ?? }` has no literal atom to search for.
        assert_eq!(r.prefiltered_forms(), (19, 1));
    }

    #[test]
    fn what_the_subset_cannot_say_is_refused_by_name() {
        for src in [
            r#"import "pe" rule X { condition: true }"#,
            r#"rule X { strings: $a = /ev[il]+/ condition: $a }"#,
            r#"rule X { strings: $a = { 4D [2-4] 5A } condition: $a }"#,
            r#"rule X { strings: $a = "x" condition: #a > 2 }"#,
            r#"rule X { strings: $a = "x" condition: $b }"#,
        ] {
            assert!(Rules::parse(src).is_err(), "{src}");
        }
    }
}