use keyhog_core::{CanonicalHexKeyMaterialSpec, DetectorSpec};
#[derive(Debug)]
struct CompiledCanonicalHexRule {
lengths: Box<[usize]>,
keywords: Box<[Box<[u8]>]>,
suffixes: Box<[Box<[u8]>]>,
excluded_keywords: Box<[Box<[u8]>]>,
}
impl CompiledCanonicalHexRule {
fn compile(spec: &CanonicalHexKeyMaterialSpec) -> Self {
Self {
lengths: sorted_lengths(&spec.lengths),
keywords: compact_keywords(&spec.keywords),
suffixes: compact_keywords(&spec.suffixes),
excluded_keywords: compact_keywords(&spec.excluded_keywords),
}
}
#[inline]
fn admits(&self, keyword: &str, value_len: usize) -> bool {
self.lengths.binary_search(&value_len).is_ok()
&& !self
.excluded_keywords
.iter()
.any(|excluded| compact_keyword_eq(keyword, excluded))
&& (self
.keywords
.iter()
.any(|owned| compact_keyword_eq(keyword, owned))
|| self
.suffixes
.iter()
.any(|suffix| compact_keyword_ends_with(keyword, suffix)))
}
}
#[derive(Debug)]
pub(crate) struct CompiledDetectorKeyMaterialPolicy {
decoded_hex_lengths: Box<[usize]>,
regex_hex_lengths: Box<[usize]>,
canonical_hex_rules: Box<[CompiledCanonicalHexRule]>,
}
impl CompiledDetectorKeyMaterialPolicy {
pub(crate) fn compile(detector: &DetectorSpec) -> Result<Self, String> {
let generic_policy = detector.kind == keyhog_core::DetectorKind::Phase2Generic;
for (index, rule) in detector.canonical_hex_key_material.iter().enumerate() {
let has_assignment_scope = !rule.keywords.is_empty()
|| !rule.suffixes.is_empty()
|| !rule.excluded_keywords.is_empty();
if generic_policy && rule.keywords.is_empty() && rule.suffixes.is_empty() {
return Err(format!(
"detector {} canonical_hex_key_material[{index}] is length-only, but phase2-generic rules require keywords or suffixes",
detector.id
));
}
if !generic_policy && has_assignment_scope {
return Err(format!(
"detector {} canonical_hex_key_material[{index}] declares assignment scope, but regex detectors require length-only rules",
detector.id
));
}
}
Ok(Self {
decoded_hex_lengths: sorted_lengths(&detector.decoded_hex_key_material_lengths),
regex_hex_lengths: if generic_policy {
Box::default()
} else {
sorted_unique_lengths(
detector
.canonical_hex_key_material
.iter()
.filter(|rule| {
rule.keywords.is_empty()
&& rule.suffixes.is_empty()
&& rule.excluded_keywords.is_empty()
})
.flat_map(|rule| rule.lengths.iter().copied()),
)
},
canonical_hex_rules: detector
.canonical_hex_key_material
.iter()
.map(CompiledCanonicalHexRule::compile)
.collect(),
})
}
#[inline]
pub(crate) fn allows_canonical_hex(&self, keyword: &str, value: &str) -> bool {
value.bytes().all(|byte| byte.is_ascii_hexdigit())
&& self
.canonical_hex_rules
.iter()
.any(|rule| rule.admits(keyword, value.len()))
}
#[inline]
pub(crate) fn allows_decoded_hex(&self, value: &str) -> bool {
value.bytes().all(|byte| byte.is_ascii_hexdigit())
&& self.allows_decoded_hex_len(Some(value.len()))
}
#[inline]
pub(crate) fn allows_decoded_hex_len(&self, decoded_len: Option<usize>) -> bool {
decoded_len.is_some_and(|len| self.decoded_hex_lengths.binary_search(&len).is_ok())
}
#[inline]
pub(crate) fn allows_canonical_hex_len(&self, value_len: usize) -> bool {
self.regex_hex_lengths.binary_search(&value_len).is_ok()
}
}
fn sorted_lengths(lengths: &[usize]) -> Box<[usize]> {
sorted_unique_lengths(lengths.iter().copied())
}
fn sorted_unique_lengths(lengths: impl IntoIterator<Item = usize>) -> Box<[usize]> {
let mut compiled = lengths.into_iter().collect::<Vec<_>>();
compiled.sort_unstable();
compiled.dedup();
compiled.into_boxed_slice()
}
fn compact_keywords(keywords: &[String]) -> Box<[Box<[u8]>]> {
keywords
.iter()
.map(|keyword| compact_keyword_bytes(keyword).collect())
.collect()
}
#[inline]
fn compact_keyword_eq(keyword: &str, compiled: &[u8]) -> bool {
compact_keyword_bytes(keyword).eq(compiled.iter().copied())
}
#[inline]
fn compact_keyword_ends_with(keyword: &str, suffix: &[u8]) -> bool {
let keyword_len = compact_keyword_bytes(keyword).count();
!suffix.is_empty()
&& keyword_len > suffix.len()
&& compact_keyword_bytes(keyword)
.skip(keyword_len - suffix.len())
.eq(suffix.iter().copied())
}
#[inline]
fn compact_keyword_bytes(keyword: &str) -> impl Iterator<Item = u8> + '_ {
keyword
.bytes()
.filter(|byte| !matches!(byte, b'_' | b'-' | b'.'))
.map(|byte| byte.to_ascii_lowercase())
}