mod com_db;
pub mod features;
mod statement;
use crate::{Error, Result};
use features::{Feature, RuleFeatureType};
use statement::{
AndStatement, Description, NotStatement, OrStatement, RangeStatement, SomeStatement, Statement,
StatementElement, SubscopeStatement,
};
use std::collections::{HashMap, HashSet};
use yaml_rust::{Yaml, YamlLoader, yaml::Hash};
use self::features::{BytesFeature, ComType};
const MAX_BYTES_FEATURE_SIZE: usize = 0x100;
fn translate_com_features(name: &str, com_type: &ComType) -> Vec<StatementElement> {
let table: &[(&str, &[[u8; 16]])] = match com_type {
ComType::Class => com_db::COM_CLASSES,
ComType::Interface => com_db::COM_INTERFACES,
};
let guids: &[[u8; 16]] = match table.binary_search_by(|(n, _)| n.cmp(&name)) {
Ok(idx) => table[idx].1,
Err(_) => return Vec::new(),
};
guids
.iter()
.filter_map(|guid| {
BytesFeature::new(guid.as_slice(), "")
.ok()
.map(|bf| StatementElement::Feature(Box::new(Feature::Bytes(bf))))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandType {
And,
Or,
Not,
Optional,
Process,
Thread,
Call,
Function,
BasicBlock,
Instruction,
Description,
CountOrMore,
Count,
Feature,
ComType,
}
impl CommandType {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self> {
match s {
"and" => Ok(CommandType::And),
"or" => Ok(CommandType::Or),
"not" => Ok(CommandType::Not),
"optional" => Ok(CommandType::Optional),
"process" => Ok(CommandType::Process),
"thread" => Ok(CommandType::Thread),
"call" => Ok(CommandType::Call),
"function" => Ok(CommandType::Function),
"basic block" => Ok(CommandType::BasicBlock),
"instruction" => Ok(CommandType::Instruction),
"description" => Ok(CommandType::Description),
s if s.ends_with(" or more") => Ok(CommandType::CountOrMore),
s if s.starts_with("count(") && s.ends_with(')') => Ok(CommandType::Count),
s if s.starts_with("com/") => Ok(CommandType::ComType),
_ => Ok(CommandType::Feature),
}
}
}
#[derive(Debug)]
pub enum Value {
Str(String),
Bytes(Vec<u8>),
Int(i128),
Null,
}
impl Value {
pub fn get_str(&self) -> Result<String> {
match self {
Value::Str(s) => Ok(s.clone()),
_ => Err(Error::InvalidRule(
line!(),
format!("{:?} need to be string", self),
)),
}
}
pub fn get_bytes(&self) -> Result<Vec<u8>> {
match self {
Value::Bytes(s) => Ok(s.clone()),
_ => Err(Error::InvalidRule(
line!(),
format!("{:?} need to be bytes array", self),
)),
}
}
pub fn get_int(&self) -> Result<i128> {
match self {
Value::Int(s) => Ok(*s),
_ => Err(Error::InvalidRule(
line!(),
format!("{:?} need to be int", self),
)),
}
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Scope {
None,
Global,
Function,
File,
BasicBlock,
Instruction,
Process,
Thread,
Call,
SpanOfCalls,
}
const STATIC_SCOPE_ORDER: &[Scope] = &[
Scope::File,
Scope::Function,
Scope::BasicBlock,
Scope::Instruction,
];
const DYNAMIC_SCOPE_ORDER: &[Scope] = &[
Scope::File,
Scope::Process,
Scope::Thread,
Scope::SpanOfCalls,
Scope::Call,
];
fn is_subscope_compatible(scope: &Scope, subscope: &Scope) -> bool {
let pos = |order: &[Scope], s: &Scope| order.iter().position(|x| x == s);
if STATIC_SCOPE_ORDER.contains(subscope) {
match (
pos(STATIC_SCOPE_ORDER, scope),
pos(STATIC_SCOPE_ORDER, subscope),
) {
(Some(scope_idx), Some(sub_idx)) => sub_idx >= scope_idx,
_ => false,
}
} else if DYNAMIC_SCOPE_ORDER.contains(subscope) {
match (
pos(DYNAMIC_SCOPE_ORDER, scope),
pos(DYNAMIC_SCOPE_ORDER, subscope),
) {
(Some(scope_idx), Some(sub_idx)) => sub_idx >= scope_idx,
_ => false,
}
} else {
false
}
}
impl TryFrom<&Yaml> for Scope {
type Error = Error;
fn try_from(value: &Yaml) -> std::result::Result<Self, Self::Error> {
Ok(match value.as_str() {
Some("global") => Scope::Global,
Some("function") => Scope::Function,
Some("span of calls") => Scope::SpanOfCalls,
Some("file") => Scope::File,
Some("basic block") => Scope::BasicBlock,
Some("instruction") => Scope::Instruction,
Some("process") => Scope::Process,
Some("thread") => Scope::Thread,
Some("call") => Scope::Call,
Some("unsupported") => Scope::None,
Some(_) => {
return Err(Error::InvalidScope(
line!(),
value.as_str().unwrap().to_string(),
));
}
None => Scope::None,
})
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct StaticScope {
scope: Scope,
}
impl TryFrom<&Yaml> for StaticScope {
type Error = Error;
fn try_from(value: &Yaml) -> std::result::Result<Self, Self::Error> {
let scope = Scope::try_from(value)?;
match scope {
Scope::None
| Scope::File
| Scope::Global
| Scope::Function
| Scope::BasicBlock
| Scope::Instruction => Ok(Self { scope }),
_ => Err(Error::InvalidStaticScope(line!())),
}
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct DynamicScope {
scope: Scope,
}
impl TryFrom<&Yaml> for DynamicScope {
type Error = Error;
fn try_from(value: &Yaml) -> std::result::Result<Self, Self::Error> {
let scope = Scope::try_from(value)?;
match scope {
Scope::None
| Scope::File
| Scope::Global
| Scope::Process
| Scope::Thread
| Scope::Call
| Scope::SpanOfCalls => Ok(Self { scope }),
_ => Err(Error::InvalidDynamicScope(line!())),
}
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Scopes {
r#static: StaticScope,
dynamic: DynamicScope,
}
impl Scopes {
pub fn try_from_dict(dict: &Yaml) -> Result<Self> {
Ok(Scopes {
r#static: StaticScope::try_from(&dict["static"])?,
dynamic: DynamicScope::try_from(&dict["dynamic"])?,
})
}
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
pub struct Rule {
pub name: String,
scopes: Scopes,
statement: StatementElement,
pub meta: Hash,
definition: String,
}
impl Rule {
pub fn set_path(&mut self, path: String) -> Result<()> {
self.meta
.insert(Yaml::String("rule-path".to_string()), Yaml::String(path));
Ok(())
}
pub fn extract_subscopes(&mut self) -> Result<Vec<Rule>> {
let mut counter = 0usize;
let mut extracted = Vec::new();
extract_subscopes_walk(
&mut self.statement,
&self.name,
&self.definition,
&mut counter,
&mut extracted,
)?;
Ok(extracted)
}
pub fn get_dependencies(
&self,
namespaces: &HashMap<String, Vec<&Rule>>,
) -> Result<Vec<String>> {
let mut deps = vec![];
fn rec(
statement: &StatementElement,
deps: &mut Vec<String>,
namespaces: &HashMap<String, Vec<&Rule>>,
) -> Result<()> {
if let StatementElement::Feature(f) = statement {
if let features::Feature::MatchedRule(s) = &**f {
if namespaces.contains_key(&s.value) {
for r in &namespaces[&s.value] {
deps.push(r.name.clone());
}
} else {
deps.push(s.value.clone());
}
}
} else if let StatementElement::Statement(s) = statement {
for child in s.get_children()? {
rec(child, deps, namespaces)?;
}
}
Ok(())
}
rec(&self.statement, &mut deps, namespaces)?;
Ok(deps)
}
fn parse_int(s: &str) -> Result<i128> {
if let Some(x) = s.strip_prefix("0x") {
Ok(i128::from_str_radix(x, 0x10)?)
} else if let Some(x) = s.strip_prefix("-0x") {
let v = i128::from_str_radix(x, 0x10)?;
Ok(-v)
} else {
Ok(s.parse::<i128>()?)
}
}
fn parse_count_u32(s: &str, raw: &str) -> Result<u32> {
let n = s.parse::<i64>().map_err(|_| {
Error::InvalidRule(line!(), format!("count value `{}` is not an integer", s))
})?;
if n < 0 {
return Err(Error::InvalidRule(
line!(),
format!("count value must be non-negative: {}", raw),
));
}
if n > u32::MAX as i64 {
return Err(Error::InvalidRule(
line!(),
format!("count value exceeds u32::MAX (~4.3B): {} in `{}`", n, raw),
));
}
Ok(n as u32)
}
fn parse_range(s: &str) -> Result<(i128, i128)> {
if !s.starts_with('(') {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
if !s.ends_with(')') {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
let s = &s["(".len()..s.len() - ")".len()];
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
let min_spec = parts[0].trim();
let max_spec = parts[1].trim();
let min = Rule::parse_int(min_spec)?;
let max = Rule::parse_int(max_spec)?;
if min < 0 || max < 0 || max < min {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
Ok((min, max))
}
fn parse_feature_type(key: &str) -> Result<RuleFeatureType> {
match key {
"api" => Ok(RuleFeatureType::Api),
"property" => Ok(RuleFeatureType::Property),
"property/read" => Ok(RuleFeatureType::PropertyRead),
"property/write" => Ok(RuleFeatureType::PropertyWrite),
"namespace" => Ok(RuleFeatureType::Namespace),
"string" => Ok(RuleFeatureType::StringFactory),
"substring" => Ok(RuleFeatureType::Substring),
"bytes" => Ok(RuleFeatureType::Bytes),
"number" => Ok(RuleFeatureType::Number(0)),
"offset" => Ok(RuleFeatureType::Offset(0)),
"mnemonic" => Ok(RuleFeatureType::Mnemonic),
"basic blocks" => Ok(RuleFeatureType::BasicBlock),
"characteristic" => Ok(RuleFeatureType::Characteristic),
"export" => Ok(RuleFeatureType::Export),
"import" => Ok(RuleFeatureType::Import),
"section" => Ok(RuleFeatureType::Section),
"match" => Ok(RuleFeatureType::MatchedRule),
"function-name" => Ok(RuleFeatureType::FunctionName),
"os" => Ok(RuleFeatureType::Os),
"format" => Ok(RuleFeatureType::Format),
"class" => Ok(RuleFeatureType::Class),
"arch" => Ok(RuleFeatureType::Arch),
_ => {
if key.starts_with("number/") {
let parts: Vec<&str> = key.split('/').collect();
let bitness = Rule::parse_int(&parts[1].trim()[1..])? as u32;
return Ok(RuleFeatureType::Number(bitness));
}
if key.starts_with("offset/") {
let parts: Vec<&str> = key.split('/').collect();
let bitness = Rule::parse_int(&parts[1].trim()[1..])? as u32;
return Ok(RuleFeatureType::Offset(bitness));
}
if let Some(rest) = key
.strip_prefix("operand[")
.and_then(|s| s.strip_suffix("].number"))
{
let idx = rest
.parse::<usize>()
.map_err(|_| Error::InvalidRule(line!(), key.to_string()))?;
return Ok(RuleFeatureType::OperandNumber(idx));
}
if let Some(rest) = key
.strip_prefix("operand[")
.and_then(|s| s.strip_suffix("].offset"))
{
let idx = rest
.parse::<usize>()
.map_err(|_| Error::InvalidRule(line!(), key.to_string()))?;
return Ok(RuleFeatureType::OperandOffset(idx));
}
Err(Error::InvalidRule(line!(), key.to_string()))
}
}
}
fn parse_bytes(s: &str) -> Result<Vec<u8>> {
let b = hex::decode(s.replace(' ', ""))?;
if b.len() > MAX_BYTES_FEATURE_SIZE {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
Ok(b)
}
fn parse_description(
s: &str,
value_type: &RuleFeatureType,
description: &Option<String>,
) -> Result<(Value, Option<String>)> {
let value; let mut dd = description.clone();
match value_type {
RuleFeatureType::String => {
value = Value::Str(s.to_string());
}
_ => {
let v; if s.contains(" = ") {
if description.is_some() {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
let parts: Vec<&str> = s.split(" = ").collect();
v = parts[0].trim();
let ddd = parts[1];
if ddd.is_empty() {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
dd = Some(ddd.to_string());
} else {
v = s;
}
value = match value_type {
RuleFeatureType::Bytes => Value::Bytes(Rule::parse_bytes(v)?),
RuleFeatureType::Number(_) | RuleFeatureType::OperandNumber(_) => {
Value::Int(Rule::parse_int(v)?)
}
RuleFeatureType::Offset(_) | RuleFeatureType::OperandOffset(_) => {
Value::Int(Rule::parse_int(v)?)
}
_ => Value::Str(v.to_string()),
};
}
}
Ok((value, dd))
}
pub fn from_yaml_file(path: &str) -> Result<Rule> {
let content = std::fs::read_to_string(path)?;
Rule::from_yaml(&content)
}
pub fn from_yaml(s: &str) -> Result<Rule> {
let doc = YamlLoader::load_from_str(s)?;
if doc.is_empty() {
return Err(Error::InvalidRule(line!(), s.to_string()));
}
Rule::from_dict(&doc[0], s)
}
pub fn from_dict(d: &Yaml, definition: &str) -> Result<Rule> {
let meta = &d["rule"]["meta"];
let name = meta["name"]
.as_str()
.ok_or_else(|| Error::InvalidRule(line!(), definition.to_string()))?;
let scopes = Scopes::try_from_dict(&meta["scopes"])?;
let statements = d["rule"]["features"]
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), definition.to_string()))?;
if statements.len() != 1 {
return Err(Error::InvalidRule(line!(), definition.to_string()));
}
match meta["att&ck"] {
Yaml::Array(_) => {}
Yaml::BadValue => {}
_ => return Err(Error::InvalidRule(line!(), definition.to_string())),
}
match meta["mbc"] {
Yaml::Array(_) => {}
Yaml::BadValue => {}
_ => return Err(Error::InvalidRule(line!(), definition.to_string())),
}
Rule::new(
name,
&scopes,
Rule::build_statements(&statements[0], &scopes)?,
&meta
.as_hash()
.ok_or_else(|| Error::InvalidRule(line!(), definition.to_string()))?
.clone(),
definition,
)
}
pub fn new(
name: &str,
scopes: &Scopes,
statement: StatementElement,
meta: &Hash,
definition: &str,
) -> Result<Rule> {
Ok(Rule {
name: name.to_string(),
scopes: scopes.clone(),
statement,
meta: meta.clone(),
definition: definition.to_string(),
})
}
pub fn extract_elements_and_description(
vals: &[Yaml],
scopes: &Scopes,
) -> Result<(Vec<StatementElement>, String)> {
let mut description = String::new();
let params = vals
.iter()
.map(|vv| Rule::build_statements(vv, scopes))
.filter_map(|result| match result {
Ok(StatementElement::Description(s)) => {
description = s.value.clone();
None
}
Ok(elem) => Some(Ok(elem)),
Err(e) => Some(Err(e)),
})
.collect::<Result<Vec<_>>>()?;
Ok((params, description))
}
fn wrap_and_subscope(
scope: Scope,
params: Vec<StatementElement>,
description: &str,
) -> Result<StatementElement> {
Ok(StatementElement::Statement(Box::new(Statement::Subscope(
SubscopeStatement::new(
scope,
StatementElement::Statement(Box::new(Statement::And(AndStatement::new(
params,
description,
)?))),
description,
)?,
))))
}
pub fn build_statements(dd: &Yaml, scopes: &Scopes) -> Result<StatementElement> {
let d = dd
.as_hash()
.ok_or_else(|| Error::InvalidRule(line!(), "statement need to be hash".to_string()))?;
if let Some((key, vval)) = d.into_iter().next() {
let key_str = key
.as_str()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", key)))?;
let command_type = CommandType::from_str(key_str)?;
match command_type {
CommandType::Description => {
let val = vval
.as_str()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
return Ok(StatementElement::Description(Box::new(Description::new(
val,
)?)));
}
CommandType::And => {
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, scopes)?;
return Ok(StatementElement::Statement(Box::new(Statement::And(
AndStatement::new(params, &description)?,
))));
}
CommandType::Or => {
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, scopes)?;
return Ok(StatementElement::Statement(Box::new(Statement::Or(
OrStatement::new(params, &description)?,
))));
}
CommandType::Not => {
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, scopes)?;
if params.len() != 1 {
return Err(Error::InvalidRule(
line!(),
format!(
"`not` requires exactly one child statement, got {}: {:?}",
params.len(),
vval
),
));
}
return Ok(StatementElement::Statement(Box::new(Statement::Not(
NotStatement::new(params[0].clone(), &description)?,
))));
}
CommandType::Optional => {
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, scopes)?;
return Ok(StatementElement::Statement(Box::new(Statement::Some(
SomeStatement::new(0, params, &description)?,
))));
}
CommandType::Process => {
if !is_subscope_compatible(&scopes.dynamic.scope, &Scope::Process) {
return Err(Error::InvalidRule(
line!(),
format!(
"`process` subscope not allowed in scope {:?}: {:?}",
scopes.dynamic.scope, vval
),
));
}
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let process_scope = Scopes {
r#static: StaticScope {
scope: Scope::Process,
},
dynamic: DynamicScope { scope: Scope::None },
};
let (params, description) =
Rule::extract_elements_and_description(val, &process_scope)?;
if params.len() != 1 {
return Err(Error::InvalidRule(
line!(),
format!("process must have exactly one condition: {:?}", vval),
));
}
return Rule::wrap_and_subscope(Scope::Process, params, &description);
}
CommandType::Thread => {
if !is_subscope_compatible(&scopes.dynamic.scope, &Scope::Thread) {
return Err(Error::InvalidRule(
line!(),
format!(
"`thread` subscope not allowed in scope {:?}: {:?}",
scopes.dynamic.scope, vval
),
));
}
let thread_scope = Scopes {
r#static: StaticScope {
scope: Scope::Thread,
},
dynamic: DynamicScope { scope: Scope::None },
};
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, &thread_scope)?;
if params.len() != 1 {
return Err(Error::InvalidRule(
line!(),
format!("thread must have exactly one condition: {:?}", vval),
));
}
return Rule::wrap_and_subscope(Scope::Thread, params, &description);
}
CommandType::Call => {
if !is_subscope_compatible(&scopes.dynamic.scope, &Scope::Call) {
return Err(Error::InvalidRule(
line!(),
format!(
"`call` subscope not allowed in scope {:?}: {:?}",
scopes.dynamic.scope, vval
),
));
}
let call_scope = Scopes {
r#static: StaticScope { scope: Scope::Call },
dynamic: DynamicScope {
scope: Scope::SpanOfCalls,
},
};
let val_list = match vval {
Yaml::Array(arr) => arr.as_slice(),
Yaml::Hash(_) => std::slice::from_ref(vval),
_ => {
return Err(Error::InvalidRule(
line!(),
format!("call expects array or hash: {:?}", vval),
));
}
};
let (params, description) =
Rule::extract_elements_and_description(val_list, &call_scope)?;
if params.len() != 1 {
return Err(Error::InvalidRule(
line!(),
format!("process must have exactly one condition: {:?}", vval),
));
}
return Rule::wrap_and_subscope(Scope::Call, params, &description);
}
CommandType::Function => {
if !is_subscope_compatible(&scopes.r#static.scope, &Scope::Function) {
return Err(Error::InvalidRule(
line!(),
format!(
"`function` subscope not allowed in scope {:?}: {:?}",
scopes.r#static.scope, vval
),
));
}
let function_scope = Scopes {
r#static: StaticScope {
scope: Scope::Function,
},
dynamic: DynamicScope { scope: Scope::None },
};
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, &function_scope)?;
if params.len() != 1 {
return Err(Error::InvalidRule(
line!(),
format!("{:?}: {:?}", key, vval),
));
}
return Ok(StatementElement::Statement(Box::new(Statement::Subscope(
SubscopeStatement::new(Scope::Function, params[0].clone(), &description)?,
))));
}
CommandType::BasicBlock => {
if !is_subscope_compatible(&scopes.r#static.scope, &Scope::BasicBlock) {
return Err(Error::InvalidRule(
line!(),
format!(
"`basic block` subscope not allowed in scope {:?}: {:?}",
scopes.r#static.scope, vval
),
));
}
let bb_scope = Scopes {
r#static: StaticScope {
scope: Scope::BasicBlock,
},
dynamic: DynamicScope { scope: Scope::None },
};
let val_list = match vval {
Yaml::Array(arr) => arr.as_slice(),
Yaml::Hash(_) => std::slice::from_ref(vval),
_ => {
return Err(Error::InvalidRule(
line!(),
format!("basic block expects array or hash: {:?}", vval),
));
}
};
let (params, description) =
Rule::extract_elements_and_description(val_list, &bb_scope)?;
if params.is_empty() {
return Err(Error::InvalidRule(
line!(),
format!("basic block must have at least one condition: {:?}", vval),
));
}
return Rule::wrap_and_subscope(Scope::BasicBlock, params, &description);
}
CommandType::Instruction => {
if !is_subscope_compatible(&scopes.r#static.scope, &Scope::Instruction) {
return Err(Error::InvalidRule(
line!(),
format!(
"`instruction` subscope not allowed in scope {:?}: {:?}",
scopes.r#static.scope, vval
),
));
}
let instruction_scope = Scopes {
r#static: StaticScope {
scope: Scope::Instruction,
},
dynamic: DynamicScope { scope: Scope::None },
};
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
let (params, description) =
Rule::extract_elements_and_description(val, &instruction_scope)?;
return Rule::wrap_and_subscope(Scope::Instruction, params, &description);
}
_ => {
let kkey = key.as_str().ok_or_else(|| {
Error::InvalidRule(line!(), format!("{:?} must be string", key))
})?;
if let Some(x) = kkey.strip_suffix(" or more") {
let count = Rule::parse_count_u32(x, kkey)?;
let mut params = vec![];
let mut description = "".to_string();
let val = vval
.as_vec()
.ok_or_else(|| Error::InvalidRule(line!(), format!("{:?}", vval)))?;
for vv in val {
let p = Rule::build_statements(vv, scopes)?;
match p {
StatementElement::Description(s) => description = s.value,
_ => params.push(p),
}
}
return Ok(StatementElement::Statement(Box::new(Statement::Some(
SomeStatement::new(count, params, &description)?,
))));
} else if kkey.starts_with("count(") && kkey.ends_with(')') {
let term = &kkey["count(".len()..kkey.len() - ")".len()];
let parts: Vec<&str> = term.split('(').collect();
let term = parts[0];
let arg = if parts.len() > 1 { parts[1] } else { "" };
let feature_type = Rule::parse_feature_type(term)?;
let arg = if !arg.is_empty() {
&arg[..arg.len() - ")".len()]
} else {
""
};
let feature = if term != "string" {
let (value, _) = Rule::parse_description(arg, &feature_type, &None)?;
Feature::new(feature_type, &value, "")?
} else {
Feature::new(feature_type, &Value::Str(arg.to_string()), "")?
};
Rule::ensure_feature_valid_for_scope(scopes, &feature)?;
match vval {
Yaml::Integer(i) => {
return Ok(StatementElement::Statement(Box::new(
Statement::Range(RangeStatement::new(
StatementElement::Feature(Box::new(feature)),
*i as u32,
*i as u32,
"",
)?),
)));
}
Yaml::String(val) => {
if let Some(num) = val.strip_suffix(" or more") {
let min = Rule::parse_count_u32(num, val)?;
return Ok(StatementElement::Statement(Box::new(
Statement::Range(RangeStatement::new(
StatementElement::Feature(Box::new(feature)),
min,
u32::MAX,
"",
)?),
)));
} else if let Some(num) = val.strip_suffix(" or fewer") {
let max = Rule::parse_count_u32(num, val)?;
return Ok(StatementElement::Statement(Box::new(
Statement::Range(RangeStatement::new(
StatementElement::Feature(Box::new(feature)),
0,
max,
"",
)?),
)));
} else if val.starts_with('(') {
let (min, max) = Rule::parse_range(val)?;
let min_u32 = u32::try_from(min).map_err(|_| {
Error::InvalidRule(
line!(),
format!(
"range min exceeds u32::MAX: {} in `{}`",
min, val
),
)
})?;
let max_u32 = u32::try_from(max).map_err(|_| {
Error::InvalidRule(
line!(),
format!(
"range max exceeds u32::MAX: {} in `{}`",
max, val
),
)
})?;
return Ok(StatementElement::Statement(Box::new(
Statement::Range(RangeStatement::new(
StatementElement::Feature(Box::new(feature)),
min_u32,
max_u32,
"",
)?),
)));
}
return Err(Error::InvalidRule(
line!(),
format!("{:?} {:?}", key, val),
));
}
_ => {
return Err(Error::InvalidRule(
line!(),
format!("{:?} {:?}", key, vval),
));
}
}
} else if let Some(stripped_key) = kkey.strip_prefix("com/") {
let com_type_name = stripped_key;
let com_type: ComType = com_type_name.try_into()?;
let val = match &d[key] {
Yaml::String(s) => s.clone(),
Yaml::Integer(i) => i.to_string(),
_ => return Err(Error::InvalidRule(line!(), format!("{:?}", d[key]))),
};
let description = match &d.get(&Yaml::String("description".to_string())) {
Some(Yaml::String(s)) => Some(s.clone()),
_ => None,
};
let (value, description) = Rule::parse_description(
&val,
&RuleFeatureType::ComType(com_type.clone()),
&description,
)?;
let d = match description {
Some(s) => s,
_ => "".to_string(),
};
let ff = translate_com_features(&value.get_str()?, &com_type);
return Ok(StatementElement::Statement(Box::new(Statement::Or(
OrStatement::new(ff, &d)?,
))));
} else {
let feature_type = Rule::parse_feature_type(kkey)?;
let val = match &d[key] {
Yaml::String(s) => s.clone(),
Yaml::Integer(i) => i.to_string(),
_ => return Err(Error::InvalidRule(line!(), format!("{:?}", d[key]))),
};
let description = match &d.get(&Yaml::String("description".to_string())) {
Some(Yaml::String(s)) => Some(s.clone()),
_ => None,
};
let (value, description) =
Rule::parse_description(&val, &feature_type, &description)?;
let d = match description {
Some(s) => s,
_ => "".to_string(),
};
let feature = Feature::new(feature_type, &value, &d)?;
Rule::ensure_feature_valid_for_scope(scopes, &feature)?;
return Ok(StatementElement::Feature(Box::new(feature)));
}
}
}
}
Err(Error::InvalidRule(line!(), "finish".to_string()))
}
pub fn ensure_feature_valid_for_scope(scopes: &Scopes, feature: &Feature) -> Result<()> {
if feature.is_supported_in_scope(scopes)? {
return Ok(());
}
Err(Error::InvalidRule(
line!(),
format!("{:?} not suported in scope {:?}", feature, scopes),
))
}
pub fn evaluate(&self, features: &HashMap<Feature, Vec<u64>>) -> Result<(bool, Vec<u64>)> {
match self.statement.evaluate(features) {
Ok(s) => Ok(s),
Err(e) => {
Err(e)
}
}
}
}
fn is_hidden(entry: &walkdir::DirEntry) -> bool {
entry
.file_name()
.to_str()
.map(|s| s.starts_with('.'))
.unwrap_or_default()
}
pub fn get_rules(rule_path: &str) -> Result<Vec<Rule>> {
use rayon::prelude::*;
let entries: Vec<String> = walkdir::WalkDir::new(rule_path)
.follow_links(false)
.into_iter()
.filter_entry(|e| !is_hidden(e))
.filter_map(|e| e.ok())
.filter_map(|e| e.path().to_str().map(str::to_string))
.filter(|fname| fname.ends_with(".yml") || fname.ends_with(".yaml"))
.collect();
let rules: Vec<Rule> = entries
.par_iter()
.map(|fname| -> Vec<Rule> {
let mut rule = match Rule::from_yaml_file(fname) {
Ok(r) => r,
Err(e) => {
eprintln!("warn: rule {} error: {:?}", fname, e);
return Vec::new();
}
};
if let Err(e) = rule.set_path(fname.clone()) {
eprintln!("warn: rule {} set_path error: {:?}", fname, e);
return Vec::new();
}
let synthetics = match rule.extract_subscopes() {
Ok(s) => s,
Err(e) => {
eprintln!("warn: rule {} subscope extraction error: {:?}", fname, e);
return Vec::new();
}
};
let mut out = Vec::with_capacity(1 + synthetics.len());
out.push(rule);
out.extend(synthetics);
out
})
.flatten()
.collect();
Ok(rules)
}
#[derive(Debug, Clone)]
pub struct RuleSet {
pub rules: Vec<Rule>,
pub basic_block_rules: Vec<Rule>,
pub function_rules: Vec<Rule>,
pub file_rules: Vec<Rule>,
}
impl RuleSet {
pub fn new(path: &str) -> Result<RuleSet> {
let rules = get_rules(path)?;
let basic_block_rules = get_basic_block_rules(&rules)?;
let function_rules = get_function_rules(&rules)?;
let file_rules = get_file_rules(&rules)?;
Ok(RuleSet {
rules: rules.clone(),
basic_block_rules: basic_block_rules.iter().map(|r| (*r).clone()).collect(),
function_rules: function_rules.iter().map(|r| (*r).clone()).collect(),
file_rules: file_rules.iter().map(|r| (*r).clone()).collect(),
})
}
pub fn filter_rules_by_meta_features(
&self,
features: &HashMap<Feature, Vec<u64>>,
) -> Result<RuleSet> {
let global_features: HashMap<Feature, Vec<u64>> = features
.iter()
.filter(|(f, _)| f.is_global_feature())
.map(|(f, l)| (f.clone(), l.clone()))
.collect();
if global_features.is_empty() {
return Ok(self.clone());
}
let namespaces = index_rules_by_namespace(&self.rules)?;
let mut keep: HashSet<String> = HashSet::with_capacity(self.rules.len());
for rule in &self.rules {
if can_match_globals(&rule.statement, &global_features) {
keep.insert(rule.name.clone());
}
}
if keep.len() == self.rules.len() {
return Ok(self.clone());
}
let mut stack: Vec<String> = keep.iter().cloned().collect();
while let Some(name) = stack.pop() {
let rule = match self.rules.iter().find(|r| r.name == name) {
Some(r) => r,
None => continue,
};
for dep in rule.get_dependencies(&namespaces)? {
if keep.insert(dep.clone()) {
stack.push(dep);
}
}
}
if keep.len() == self.rules.len() {
return Ok(self.clone());
}
let filtered: Vec<Rule> = self
.rules
.iter()
.filter(|r| keep.contains(&r.name))
.cloned()
.collect();
let basic_block_rules = get_basic_block_rules(&filtered)?
.iter()
.map(|r| (*r).clone())
.collect();
let function_rules = get_function_rules(&filtered)?
.iter()
.map(|r| (*r).clone())
.collect();
let file_rules = get_file_rules(&filtered)?
.iter()
.map(|r| (*r).clone())
.collect();
Ok(RuleSet {
rules: filtered,
basic_block_rules,
function_rules,
file_rules,
})
}
}
fn can_match_globals(node: &StatementElement, globals: &HashMap<Feature, Vec<u64>>) -> bool {
match node {
StatementElement::Feature(f) => {
if f.is_global_feature() {
let wildcard = match f.as_ref() {
Feature::Os(o) => o.value() == "any",
Feature::Arch(a) => a.value() == "any",
Feature::Format(fmt) => fmt.value() == "any",
_ => false,
};
if wildcard {
return true;
}
f.evaluate(globals).map(|(b, _)| b).unwrap_or(true)
} else {
true
}
}
StatementElement::Description(_) => true,
StatementElement::Statement(boxed) => match boxed.as_ref() {
Statement::And(s) => match s.get_children() {
Ok(children) => children.iter().all(|c| can_match_globals(c, globals)),
Err(_) => true,
},
Statement::Or(s) => match s.get_children() {
Ok(children) => children.iter().any(|c| can_match_globals(c, globals)),
Err(_) => true,
},
Statement::Not(_) => true,
Statement::Some(s) => {
if s.count() == 0 {
return true;
}
let children = match s.get_children() {
Ok(c) => c,
Err(_) => return true,
};
let satisfiable: u32 = children
.iter()
.map(|c| if can_match_globals(c, globals) { 1 } else { 0 })
.sum();
satisfiable >= s.count()
}
Statement::Range(s) => {
if s.min() == 0 {
return true;
}
match s.get_children() {
Ok(children) => children.iter().all(|c| can_match_globals(c, globals)),
Err(_) => true,
}
}
Statement::Subscope(_) => match boxed.get_children() {
Ok(children) => children.iter().all(|c| can_match_globals(c, globals)),
Err(_) => true,
},
},
}
}
pub fn get_instruction_rules(rules: &[Rule]) -> Result<Vec<&Rule>> {
get_rules_for_scope(rules, &Scope::Instruction)
}
pub fn get_basic_block_rules(rules: &[Rule]) -> Result<Vec<&Rule>> {
get_rules_for_scope(rules, &Scope::BasicBlock)
}
pub fn get_function_rules(rules: &[Rule]) -> Result<Vec<&Rule>> {
get_rules_for_scope(rules, &Scope::Function)
}
pub fn get_file_rules(rules: &[Rule]) -> Result<Vec<&Rule>> {
get_rules_for_scope(rules, &Scope::File)
}
pub(crate) fn rule_meta_bool(rule: &Rule, key: &str) -> bool {
matches!(
rule.meta.get(&Yaml::String(key.to_string())),
Some(Yaml::Boolean(true))
)
}
pub(crate) fn is_lib_rule(rule: &Rule) -> bool {
rule_meta_bool(rule, "lib")
}
fn scopes_for_subscope(target: &Scope) -> Scopes {
let is_static = matches!(
target,
Scope::File | Scope::Function | Scope::BasicBlock | Scope::Instruction
);
if is_static {
Scopes {
r#static: StaticScope {
scope: target.clone(),
},
dynamic: DynamicScope { scope: Scope::None },
}
} else {
Scopes {
r#static: StaticScope { scope: Scope::None },
dynamic: DynamicScope {
scope: target.clone(),
},
}
}
}
fn extract_subscopes_walk(
elem: &mut StatementElement,
parent_name: &str,
parent_definition: &str,
counter: &mut usize,
extracted: &mut Vec<Rule>,
) -> Result<()> {
if let StatementElement::Statement(s) = elem {
for c in s.children_mut() {
extract_subscopes_walk(c, parent_name, parent_definition, counter, extracted)?;
}
}
let target_scope = match elem {
StatementElement::Statement(s) => match s.as_ref() {
Statement::Subscope(sub) => Some(sub.scope().clone()),
_ => None,
},
_ => None,
};
let Some(target_scope) = target_scope else {
return Ok(());
};
if !matches!(target_scope, Scope::Function | Scope::BasicBlock) {
return Ok(());
}
let idx = *counter;
*counter += 1;
let synth_name = format!("{}/subscope/{}", parent_name, idx);
let placeholder = StatementElement::Description(Box::new(Description::new("").unwrap()));
let owned = std::mem::replace(elem, placeholder);
let StatementElement::Statement(boxed) = owned else {
unreachable!("checked Statement above")
};
let Statement::Subscope(sub) = *boxed else {
unreachable!("checked Subscope above")
};
let (target_scope, child, description) = sub.into_inner();
let scopes = scopes_for_subscope(&target_scope);
let mut meta = Hash::new();
meta.insert(
Yaml::String("name".to_string()),
Yaml::String(synth_name.clone()),
);
meta.insert(
Yaml::String("capa/subscope-rule".to_string()),
Yaml::Boolean(true),
);
if !description.is_empty() {
meta.insert(
Yaml::String("description".to_string()),
Yaml::String(description.clone()),
);
}
let synth = Rule::new(&synth_name, &scopes, child, &meta, parent_definition)?;
extracted.push(synth);
let matched = features::MatchedRuleFeature::new(&synth_name, "")?;
*elem = StatementElement::Feature(Box::new(Feature::MatchedRule(matched)));
Ok(())
}
pub fn get_rules_for_scope<'a>(rules: &'a [Rule], scope: &Scope) -> Result<Vec<&'a Rule>> {
let namespaces = index_rules_by_namespace(rules)?;
let rules_by_name = build_rules_by_name(rules);
let mut scope_rules = vec![];
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for rule in rules {
if rule_meta_bool(rule, "capa/subscope-rule") || is_lib_rule(rule) {
continue;
}
let deps =
get_rules_and_dependencies_indexed(rules, &rules_by_name, &namespaces, &rule.name)?;
for r in deps {
if seen.insert(r.name.clone()) {
scope_rules.push(r);
}
}
}
let trules = topologically_order_rules(scope_rules)?;
get_rules_with_scope(trules, scope)
}
fn build_rules_by_name(rules: &[Rule]) -> HashMap<String, &Rule> {
let mut rules_by_name = HashMap::with_capacity(rules.len());
for rule in rules {
rules_by_name.insert(rule.name.clone(), rule);
}
rules_by_name
}
pub fn get_rules_and_dependencies<'a>(rules: &'a [Rule], rule_name: &str) -> Result<Vec<&'a Rule>> {
let namespaces = index_rules_by_namespace(rules)?;
let rules_by_name = build_rules_by_name(rules);
get_rules_and_dependencies_indexed(rules, &rules_by_name, &namespaces, rule_name)
}
fn get_rules_and_dependencies_indexed<'a>(
_rules: &'a [Rule],
rules_by_name: &HashMap<String, &'a Rule>,
namespaces: &HashMap<String, Vec<&'a Rule>>,
rule_name: &str,
) -> Result<Vec<&'a Rule>> {
let mut wanted: std::collections::HashSet<String> = std::collections::HashSet::new();
fn rec<'a>(
want: &mut std::collections::HashSet<String>,
rule: &'a Rule,
rules_by_name: &HashMap<String, &'a Rule>,
namespaces: &HashMap<String, Vec<&'a Rule>>,
) -> Result<()> {
if !want.insert(rule.name.clone()) {
return Ok(());
}
for dep in rule.get_dependencies(namespaces)? {
match rules_by_name.get(&dep) {
Some(dep_rule) => {
rec(want, dep_rule, rules_by_name, namespaces)?;
}
None => {
eprintln!("Rule not found: {}", dep);
return Err(Error::MatchRuleNotFound(format!(
"Rule '{}' not found in the rules set",
dep
)));
}
}
}
Ok(())
}
let seed = rules_by_name
.get(rule_name)
.ok_or_else(|| Error::MatchRuleNotFound(rule_name.to_string()))?;
rec(&mut wanted, seed, rules_by_name, namespaces)?;
let mut res = Vec::with_capacity(wanted.len());
for name in &wanted {
if let Some(rule) = rules_by_name.get(name) {
res.push(*rule);
}
}
Ok(res)
}
pub fn get_rules_with_scope<'a>(rules: Vec<&'a Rule>, scope: &Scope) -> Result<Vec<&'a Rule>> {
let mut res = vec![];
for rule in rules {
if &rule.scopes.r#static.scope == scope || &rule.scopes.dynamic.scope == scope {
res.push(rule);
}
}
Ok(res)
}
fn generate_namespace_paths(namespace: &str) -> Vec<String> {
namespace
.split('/')
.scan(String::new(), |state, part| {
if !state.is_empty() {
state.push('/');
}
state.push_str(part);
Some(state.clone())
})
.collect()
}
pub fn index_rules_by_namespace(rules: &[Rule]) -> Result<HashMap<String, Vec<&Rule>>> {
let mut namespaces: HashMap<String, Vec<&Rule>> = HashMap::new();
for rule in rules {
if let Some(Yaml::String(namespace)) = rule.meta.get(&Yaml::String("namespace".to_string()))
{
for path in generate_namespace_paths(namespace) {
namespaces.entry(path).or_default().push(rule);
}
}
}
Ok(namespaces)
}
pub fn index_rules_by_namespace2<'a>(rules: &[&'a Rule]) -> Result<HashMap<String, Vec<&'a Rule>>> {
let mut namespaces: HashMap<String, Vec<&'a Rule>> = HashMap::new();
for &rule in rules {
if let Some(Yaml::String(namespace)) = rule.meta.get(&Yaml::String("namespace".to_string()))
{
for path in generate_namespace_paths(namespace) {
namespaces.entry(path).or_default().push(rule);
}
}
}
Ok(namespaces)
}
pub fn topologically_order_rules(rules: Vec<&Rule>) -> Result<Vec<&Rule>> {
let namespaces = index_rules_by_namespace2(&rules)?;
let mut rules_by_name = HashMap::new();
for rule in &rules {
rules_by_name.insert(rule.name.clone(), *rule);
}
let mut seen = std::collections::HashSet::new();
let mut ret = vec![];
fn rec<'a>(
rule: &'a Rule,
seen: &mut std::collections::HashSet<String>,
rules_by_name: &HashMap<String, &'a Rule>,
namespaces: &HashMap<String, Vec<&'a Rule>>,
) -> Result<Vec<&'a Rule>> {
if seen.contains(&rule.name) {
return Ok(vec![]);
}
let mut rett = vec![];
for dep in rule.get_dependencies(namespaces)? {
rett.append(&mut rec(
rules_by_name[&dep],
seen,
rules_by_name,
namespaces,
)?);
}
rett.push(rule);
seen.insert(rule.name.clone());
Ok(rett)
}
for rule in rules_by_name.values() {
ret.append(&mut rec(rule, &mut seen, &rules_by_name, &namespaces)?);
}
Ok(ret)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::features::OsFeature;
fn make_ruleset(rules: Vec<Rule>) -> Result<RuleSet> {
let basic_block_rules = get_basic_block_rules(&rules)?
.iter()
.map(|r| (*r).clone())
.collect();
let function_rules = get_function_rules(&rules)?
.iter()
.map(|r| (*r).clone())
.collect();
let file_rules = get_file_rules(&rules)?
.iter()
.map(|r| (*r).clone())
.collect();
Ok(RuleSet {
rules,
basic_block_rules,
function_rules,
file_rules,
})
}
fn os_globals(value: &str) -> HashMap<Feature, Vec<u64>> {
let mut m = HashMap::new();
m.insert(
Feature::Os(OsFeature::new(value, "").expect("OsFeature::new")),
vec![0],
);
m
}
fn names(rs: &RuleSet) -> HashSet<String> {
rs.rules.iter().map(|r| r.name.clone()).collect()
}
const WINDOWS_RULE: &str = r#"
rule:
meta:
name: windows only
scopes:
static: function
dynamic: process
features:
- and:
- os: windows
- api: CreateFile
"#;
const LINUX_RULE: &str = r#"
rule:
meta:
name: linux only
scopes:
static: function
dynamic: process
features:
- and:
- os: linux
- api: open
"#;
const ANY_OS_RULE: &str = r#"
rule:
meta:
name: any os
scopes:
static: function
dynamic: process
features:
- and:
- os: any
- api: malloc
"#;
const NO_OS_RULE: &str = r#"
rule:
meta:
name: no os
scopes:
static: function
dynamic: process
features:
- api: calloc
"#;
#[test]
fn upstream_parity_2929_prunes_incompatible_os() {
let rs = make_ruleset(vec![
Rule::from_yaml(WINDOWS_RULE).expect("windows yaml"),
Rule::from_yaml(LINUX_RULE).expect("linux yaml"),
])
.expect("RuleSet");
let filtered = rs
.filter_rules_by_meta_features(&os_globals("linux"))
.expect("filter");
let ns = names(&filtered);
assert!(ns.contains("linux only"), "kept: {:?}", ns);
assert!(!ns.contains("windows only"), "kept: {:?}", ns);
let filtered = rs
.filter_rules_by_meta_features(&os_globals("windows"))
.expect("filter");
let ns = names(&filtered);
assert!(ns.contains("windows only"), "kept: {:?}", ns);
assert!(!ns.contains("linux only"), "kept: {:?}", ns);
}
#[test]
fn upstream_parity_2929_keeps_any_os_and_no_os() {
let rs = make_ruleset(vec![
Rule::from_yaml(ANY_OS_RULE).expect("any-os yaml"),
Rule::from_yaml(NO_OS_RULE).expect("no-os yaml"),
])
.expect("RuleSet");
for os in ["windows", "linux"] {
let filtered = rs
.filter_rules_by_meta_features(&os_globals(os))
.expect("filter");
let ns = names(&filtered);
assert!(
ns.contains("any os"),
"any-os pruned for os={os}, kept: {ns:?}"
);
assert!(
ns.contains("no os"),
"no-os pruned for os={os}, kept: {ns:?}"
);
}
}
#[test]
fn upstream_parity_2929_empty_globals_returns_clone() {
let rs = make_ruleset(vec![
Rule::from_yaml(WINDOWS_RULE).expect("yaml"),
Rule::from_yaml(LINUX_RULE).expect("yaml"),
])
.expect("RuleSet");
let filtered = rs
.filter_rules_by_meta_features(&HashMap::new())
.expect("filter");
assert_eq!(
filtered.rules.len(),
2,
"empty globals should keep all rules"
);
}
const PARENT_USES_LINUX_DEP: &str = r#"
rule:
meta:
name: windows parent
scopes:
static: function
dynamic: process
features:
- and:
- os: windows
- or:
- api: CreateFile
- match: linux dep
"#;
const LINUX_DEP: &str = r#"
rule:
meta:
name: linux dep
scopes:
static: function
dynamic: process
features:
- and:
- os: linux
- api: open
"#;
#[test]
fn upstream_parity_2929_keeps_dependencies_of_surviving_rules() {
let rs = make_ruleset(vec![
Rule::from_yaml(LINUX_DEP).expect("dep yaml"),
Rule::from_yaml(PARENT_USES_LINUX_DEP).expect("parent yaml"),
])
.expect("RuleSet");
let filtered = rs
.filter_rules_by_meta_features(&os_globals("windows"))
.expect("filter");
let ns = names(&filtered);
assert!(ns.contains("windows parent"), "parent pruned: {ns:?}");
assert!(
ns.contains("linux dep"),
"transitive dep pruned (broken dependency invariant): {ns:?}"
);
}
const UNREACHABLE_SOME_RULE: &str = r#"
rule:
meta:
name: unreachable some
scopes:
static: function
dynamic: process
features:
- 3 or more:
- os: windows
- os: linux
- os: macos
"#;
#[test]
fn upstream_parity_2929_prunes_unreachable_some_count() {
let rs = make_ruleset(vec![Rule::from_yaml(UNREACHABLE_SOME_RULE).expect("yaml")])
.expect("RuleSet");
let filtered = rs
.filter_rules_by_meta_features(&os_globals("windows"))
.expect("filter");
let ns = names(&filtered);
assert!(
!ns.contains("unreachable some"),
"unsatisfiable Some-count rule was not pruned: {ns:?}"
);
}
}