use crate::compiled_scanner::GpuInitPolicy;
use crate::compiler::compiler_build::CompileState;
use crate::engine::CompiledScanner;
use crate::error::{Result, ScanError};
use crate::execution_pack::matcher_sections::{
decode_local_matcher_artifact_compile_state_sections, CompiledRouteMatcherSections,
};
use crate::execution_pack::{CanonicalDetectorExecutionIr, ExecutionPackBackend};
use crate::hw_probe::ScanBackend;
use crate::types::ScannerTuningConfig;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
pub use keyhog_core::MATCHER_ARTIFACT_FORMAT_VERSION as MATCHER_ARTIFACT_VERSION;
pub use keyhog_core::MATCHER_ARTIFACT_MAGIC;
pub use keyhog_core::MATCHER_ARTIFACT_SUFFIX;
pub const MATCHER_ARTIFACT_FILE_BYTES: u64 = 256 * 1024 * 1024;
static CONFIGURED_CACHE_DIR: OnceLock<parking_lot::RwLock<Option<PathBuf>>> = OnceLock::new();
fn configured_cache_dir_cell() -> &'static parking_lot::RwLock<Option<PathBuf>> {
CONFIGURED_CACHE_DIR.get_or_init(|| parking_lot::RwLock::new(None))
}
pub fn set_matcher_artifact_cache_dir(path: Option<PathBuf>) {
*configured_cache_dir_cell().write() = path;
}
pub fn configured_matcher_artifact_cache_dir() -> Option<PathBuf> {
configured_cache_dir_cell().read().clone()
}
pub fn default_matcher_artifact_cache_dir() -> std::result::Result<PathBuf, String> {
default_matcher_artifact_cache_dir_from_base(dirs::cache_dir())
}
pub fn default_matcher_artifact_cache_dir_from_base(
base: Option<PathBuf>,
) -> std::result::Result<PathBuf, String> {
let base = base.ok_or_else(|| {
"could not determine a platform cache directory for matcher artifacts; configure \
--matcher-cache <DIR|off> or [system].matcher_cache"
.to_owned()
})?;
Ok(base.join(keyhog_core::KEYHOG_MATCHER_ARTIFACTS_SUBDIR))
}
pub fn validate_matcher_artifact_cache_dir(path: &Path) -> std::result::Result<(), String> {
if !path.is_absolute() {
return Err(format!(
"matcher-artifact cache dir '{}' must be absolute",
path.display()
));
}
let home = dirs::home_dir().ok_or_else(|| "could not determine HOME directory".to_owned())?;
let uid = current_uid();
let temp_root = std::env::temp_dir();
let tmp_user_dir = temp_root.join(format!("keyhog-cache-{uid}"));
if !(path.starts_with(&home) || path.starts_with(&tmp_user_dir)) {
return Err(format!(
"matcher-artifact cache dir must be under {} or {}",
home.display(),
tmp_user_dir.display()
));
}
if path.exists() {
let meta = std::fs::symlink_metadata(path).map_err(|error| {
format!("could not read matcher-artifact cache dir metadata: {error}")
})?;
if meta.file_type().is_symlink() {
return Err("matcher-artifact cache dir cannot be a symlink".to_owned());
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.uid() != uid {
return Err(
"matcher-artifact cache directory is not owned by the current user".to_owned(),
);
}
if meta.mode() & 0o022 != 0 {
return Err(
"matcher-artifact cache directory must not be group- or world-writable"
.to_owned(),
);
}
}
}
Ok(())
}
fn current_uid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::geteuid() }
}
#[cfg(not(unix))]
{
0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MatcherArtifactCacheOutcome {
Disabled,
Hit,
Miss,
Invalidated {
reason: String,
},
}
impl MatcherArtifactCacheOutcome {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Hit => "hit",
Self::Miss => "miss",
Self::Invalidated { .. } => "invalidated",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MatcherArtifactIdentity {
pub version: u32,
pub binary_digest: String,
pub binary_version: String,
pub git_hash: String,
pub target: String,
pub features: String,
pub detector_corpus_digest: String,
pub resolved_config_digest: String,
pub pack_generation: String,
pub backend: String,
pub runtime_identity: String,
pub route_matcher_section_version: u16,
}
impl MatcherArtifactIdentity {
pub fn new(
detector_corpus_digest: [u8; 32],
resolved_config_digest: [u8; 32],
pack_generation: Option<&str>,
backend: ExecutionPackBackend,
runtime_identity: Option<&str>,
) -> std::result::Result<Self, String> {
Ok(Self {
version: MATCHER_ARTIFACT_VERSION,
binary_digest: current_executable_sha256()?,
binary_version: env!("CARGO_PKG_VERSION").to_owned(),
git_hash: keyhog_core::git_hash().to_owned(),
target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
features: scanner_feature_identity(),
detector_corpus_digest: keyhog_core::hex_encode(&detector_corpus_digest),
resolved_config_digest: keyhog_core::hex_encode(&resolved_config_digest),
pack_generation: pack_generation.unwrap_or("none").to_owned(),
backend: backend.pascal_name().to_owned(),
runtime_identity: runtime_identity.unwrap_or("none").to_owned(),
route_matcher_section_version: crate::execution_pack::ROUTE_MATCHER_SECTION_VERSION,
})
}
pub fn digest(&self) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
update_tagged(
&mut hasher,
b"domain",
b"keyhog-matcher-artifact-identity-v1",
);
update_tagged(&mut hasher, b"version", &self.version.to_le_bytes());
update_tagged(&mut hasher, b"binary_digest", self.binary_digest.as_bytes());
update_tagged(
&mut hasher,
b"binary_version",
self.binary_version.as_bytes(),
);
update_tagged(&mut hasher, b"git_hash", self.git_hash.as_bytes());
update_tagged(&mut hasher, b"target", self.target.as_bytes());
update_tagged(&mut hasher, b"features", self.features.as_bytes());
update_tagged(
&mut hasher,
b"detector_corpus_digest",
self.detector_corpus_digest.as_bytes(),
);
update_tagged(
&mut hasher,
b"resolved_config_digest",
self.resolved_config_digest.as_bytes(),
);
update_tagged(
&mut hasher,
b"pack_generation",
self.pack_generation.as_bytes(),
);
update_tagged(&mut hasher, b"backend", self.backend.as_bytes());
update_tagged(
&mut hasher,
b"runtime_identity",
self.runtime_identity.as_bytes(),
);
update_tagged(
&mut hasher,
b"route_matcher_section_version",
&self.route_matcher_section_version.to_le_bytes(),
);
*hasher.finalize().as_bytes()
}
pub fn cache_filename(&self) -> String {
format!(
"{}{}{}",
keyhog_core::MATCHER_ARTIFACT_FILENAME_PREFIX,
keyhog_core::hex_encode(&self.digest()),
MATCHER_ARTIFACT_SUFFIX
)
}
}
fn update_tagged(hasher: &mut blake3::Hasher, tag: &[u8], value: &[u8]) {
hasher.update(&(tag.len() as u64).to_le_bytes());
hasher.update(tag);
hasher.update(&(value.len() as u64).to_le_bytes());
hasher.update(value);
}
fn scanner_feature_identity() -> String {
let mut features = Vec::new();
macro_rules! push_feature {
($name:literal) => {
if cfg!(feature = $name) {
features.push($name);
}
};
}
push_feature!("ml");
push_feature!("entropy");
push_feature!("decode");
push_feature!("multiline");
push_feature!("simd");
push_feature!("simdsieve");
push_feature!("gpu");
push_feature!("static-hyperscan");
features.join(",")
}
pub fn execution_pack_backend_for_scan_backend(
backend: ScanBackend,
) -> Option<ExecutionPackBackend> {
ExecutionPackBackend::from_scan_backend(backend)
}
pub fn matcher_backend_for_gpu_policy(policy: GpuInitPolicy) -> Option<ExecutionPackBackend> {
match policy {
GpuInitPolicy::SelectedBackend(backend) => execution_pack_backend_for_scan_backend(backend),
GpuInitPolicy::ForceDisabled
| GpuInitPolicy::FromRuntimePolicy
| GpuInitPolicy::ForceEnabled => Some(ExecutionPackBackend::Cpu),
}
}
fn current_executable_sha256() -> std::result::Result<String, String> {
keyhog_core::current_executable_sha256()
}
fn read_u32_le(bytes: &[u8], offset: &mut usize, path: &Path) -> std::result::Result<u32, String> {
let end = offset
.checked_add(4)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| format!("matcher artifact {} is truncated", path.display()))?;
let arr: [u8; 4] = bytes[*offset..end]
.try_into()
.map_err(|_| format!("matcher artifact {} is truncated", path.display()))?;
let value = u32::from_le_bytes(arr);
*offset = end;
Ok(value)
}
fn read_exact<'a>(
bytes: &'a [u8],
offset: &mut usize,
len: usize,
path: &Path,
) -> std::result::Result<&'a [u8], String> {
let end = offset
.checked_add(len)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| format!("matcher artifact {} is truncated", path.display()))?;
let slice = &bytes[*offset..end];
*offset = end;
Ok(slice)
}
#[derive(Clone, Debug)]
pub struct LoadedMatcherArtifact {
pub sections: CompiledRouteMatcherSections,
}
#[derive(Clone, Debug)]
struct MatcherArtifactSectionRanges {
backend: ExecutionPackBackend,
literal_index: Range<usize>,
regex_programs: Range<usize>,
suppression_policy: Range<usize>,
}
#[derive(Debug)]
struct BorrowedMatcherArtifact {
bytes: Vec<u8>,
ranges: MatcherArtifactSectionRanges,
}
impl BorrowedMatcherArtifact {
fn section_bytes(&self) -> (&[u8], &[u8], &[u8]) {
(
&self.bytes[self.ranges.literal_index.clone()],
&self.bytes[self.ranges.regex_programs.clone()],
&self.bytes[self.ranges.suppression_policy.clone()],
)
}
fn to_owned_sections(&self) -> CompiledRouteMatcherSections {
let (literal_index, regex_programs, suppression_policy) = self.section_bytes();
CompiledRouteMatcherSections {
backend: self.ranges.backend,
literal_index: literal_index.to_vec(),
regex_programs: regex_programs.to_vec(),
suppression_policy: suppression_policy.to_vec(),
}
}
}
fn parse_matcher_artifact_ranges(
path: &Path,
bytes: &[u8],
expected_identity: Option<&MatcherArtifactIdentity>,
) -> std::result::Result<(MatcherArtifactIdentity, MatcherArtifactSectionRanges), String> {
if bytes.len() < 8 {
return Err(format!("matcher artifact {} is truncated", path.display()));
}
if &bytes[..4] != MATCHER_ARTIFACT_MAGIC {
return Err(format!(
"matcher artifact {} has invalid magic",
path.display()
));
}
let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
if version != MATCHER_ARTIFACT_VERSION {
return Err(format!(
"matcher artifact {} version {version} is incompatible with {MATCHER_ARTIFACT_VERSION}",
path.display()
));
}
let mut offset = 8usize;
let identity_len = read_u32_le(bytes, &mut offset, path)? as usize;
let identity_bytes = read_exact(bytes, &mut offset, identity_len, path)?;
let decoded_identity: MatcherArtifactIdentity = serde_json::from_slice(identity_bytes)
.map_err(|error| {
format!(
"matcher artifact {} identity is not valid JSON: {error}",
path.display()
)
})?;
if let Some(expected) = expected_identity {
if decoded_identity != *expected {
return Err(format!(
"matcher artifact {} identity fields do not match the running scan",
path.display()
));
}
}
let stored_identity_digest: [u8; 32] = read_exact(bytes, &mut offset, 32, path)?
.try_into()
.map_err(|_| format!("matcher artifact {} is truncated", path.display()))?;
let expected_digest = decoded_identity.digest();
if stored_identity_digest != expected_digest {
return Err(format!(
"matcher artifact {} identity digest mismatch",
path.display()
));
}
let stored_content_digest: [u8; 32] = read_exact(bytes, &mut offset, 32, path)?
.try_into()
.map_err(|_| format!("matcher artifact {} is truncated", path.display()))?;
let literal_len = read_u32_le(bytes, &mut offset, path)? as usize;
let literal_start = offset;
read_exact(bytes, &mut offset, literal_len, path)?;
let literal_index = literal_start..offset;
let regex_len = read_u32_le(bytes, &mut offset, path)? as usize;
let regex_start = offset;
read_exact(bytes, &mut offset, regex_len, path)?;
let regex_programs = regex_start..offset;
let supp_len = read_u32_le(bytes, &mut offset, path)? as usize;
let suppression_start = offset;
read_exact(bytes, &mut offset, supp_len, path)?;
let suppression_policy = suppression_start..offset;
if offset != bytes.len() {
return Err(format!(
"matcher artifact {} has trailing bytes after the envelope",
path.display()
));
}
let backend =
ExecutionPackBackend::from_pascal_name(&decoded_identity.backend).ok_or_else(|| {
format!(
"matcher artifact {} has unknown backend {}",
path.display(),
decoded_identity.backend
)
})?;
let content = CompiledRouteMatcherSections::content_digest_for(
&bytes[literal_index.clone()],
&bytes[regex_programs.clone()],
&bytes[suppression_policy.clone()],
);
if content != stored_content_digest {
return Err(format!(
"matcher artifact {} content digest mismatch",
path.display()
));
}
Ok((
decoded_identity,
MatcherArtifactSectionRanges {
backend,
literal_index,
regex_programs,
suppression_policy,
},
))
}
fn load_borrowed_matcher_artifact(
cache_dir: &Path,
identity: &MatcherArtifactIdentity,
) -> std::result::Result<BorrowedMatcherArtifact, String> {
let path = cache_dir.join(identity.cache_filename());
let bytes = read_matcher_artifact_bytes(&path)?;
let (_identity, ranges) = parse_matcher_artifact_ranges(&path, &bytes, Some(identity))?;
Ok(BorrowedMatcherArtifact { bytes, ranges })
}
pub fn load_matcher_artifact(
cache_dir: &Path,
identity: &MatcherArtifactIdentity,
) -> std::result::Result<CompiledRouteMatcherSections, String> {
Ok(load_matcher_artifact_with_ir(cache_dir, identity)?.sections)
}
pub fn load_matcher_artifact_with_ir(
cache_dir: &Path,
identity: &MatcherArtifactIdentity,
) -> std::result::Result<LoadedMatcherArtifact, String> {
let loaded = load_borrowed_matcher_artifact(cache_dir, identity)?;
Ok(LoadedMatcherArtifact {
sections: loaded.to_owned_sections(),
})
}
fn read_capped_matcher_artifact(
file: std::fs::File,
metadata_len: u64,
path: &Path,
) -> std::result::Result<Vec<u8>, String> {
let mut bytes = Vec::with_capacity(metadata_len.min(MATCHER_ARTIFACT_FILE_BYTES) as usize);
file.take(MATCHER_ARTIFACT_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read matcher artifact {}: {error}", path.display()))?;
if bytes.len() as u64 > MATCHER_ARTIFACT_FILE_BYTES {
return Err(format!(
"matcher artifact {} exceeds {} byte cap",
path.display(),
MATCHER_ARTIFACT_FILE_BYTES
));
}
Ok(bytes)
}
fn read_matcher_artifact_bytes(path: &Path) -> std::result::Result<Vec<u8>, String> {
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
let mut options = std::fs::OpenOptions::new();
options.read(true);
options.custom_flags(libc::O_NOFOLLOW);
let file = match options.open(path) {
Ok(file) => file,
Err(error) => {
if error.raw_os_error() == Some(libc::ELOOP) {
return Err(format!(
"matcher artifact {} is a symlink; refusing to load",
path.display()
));
}
if error.kind() == std::io::ErrorKind::NotFound {
return Err(format!("matcher artifact cache miss: {}", path.display()));
}
return Err(format!(
"cannot open matcher artifact {}: {error}",
path.display()
));
}
};
let metadata = file.metadata().map_err(|error| {
format!("cannot fstat matcher artifact {}: {error}", path.display())
})?;
if !metadata.file_type().is_file() {
return Err(format!(
"matcher artifact {} is not a regular file; refusing to load",
path.display()
));
}
if metadata.uid() != current_uid() {
return Err(format!(
"matcher artifact {} is not owned by the current user; refusing to load",
path.display()
));
}
if metadata.len() > MATCHER_ARTIFACT_FILE_BYTES {
return Err(format!(
"matcher artifact {} exceeds {} byte cap",
path.display(),
MATCHER_ARTIFACT_FILE_BYTES
));
}
return read_capped_matcher_artifact(file, metadata.len(), path);
}
#[cfg(not(unix))]
{
let metadata = std::fs::symlink_metadata(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
format!("matcher artifact cache miss: {}", path.display())
} else {
format!("cannot stat matcher artifact {}: {error}", path.display())
}
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"matcher artifact {} is a symlink; refusing to load",
path.display()
));
}
if metadata.len() > MATCHER_ARTIFACT_FILE_BYTES {
return Err(format!(
"matcher artifact {} exceeds {} byte cap",
path.display(),
MATCHER_ARTIFACT_FILE_BYTES
));
}
let file = std::fs::File::open(path)
.map_err(|error| format!("cannot open matcher artifact {}: {error}", path.display()))?;
read_capped_matcher_artifact(file, metadata.len(), path)
}
}
fn checked_matcher_artifact_len(
identity_len: usize,
literal_len: usize,
regex_len: usize,
suppression_len: usize,
) -> std::result::Result<usize, String> {
let artifact_len = [
8usize,
4,
identity_len,
64,
12,
literal_len,
regex_len,
suppression_len,
]
.into_iter()
.try_fold(0usize, usize::checked_add)
.ok_or_else(|| "matcher artifact size overflow".to_owned())?;
if artifact_len as u64 > MATCHER_ARTIFACT_FILE_BYTES {
return Err(format!(
"matcher artifact would exceed {} byte cap",
MATCHER_ARTIFACT_FILE_BYTES
));
}
Ok(artifact_len)
}
pub fn store_matcher_artifact(
cache_dir: &Path,
identity: &MatcherArtifactIdentity,
sections: &CompiledRouteMatcherSections,
) -> std::result::Result<(), String> {
let expected_backend = ExecutionPackBackend::from_pascal_name(&identity.backend)
.ok_or_else(|| "unknown identity backend".to_owned())?;
if sections.backend != expected_backend {
return Err("matcher artifact backend does not match identity".to_owned());
}
validate_matcher_artifact_cache_dir(cache_dir)?;
let created_cache_dir = !cache_dir.exists();
std::fs::create_dir_all(cache_dir).map_err(|error| {
format!(
"cannot create matcher-artifact cache dir {}: {error}",
cache_dir.display()
)
})?;
#[cfg(unix)]
if created_cache_dir {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(cache_dir)
.map_err(|error| {
format!(
"cannot stat matcher-artifact cache dir {}: {error}",
cache_dir.display()
)
})?
.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(cache_dir, perms).map_err(|error| {
format!(
"cannot tighten matcher-artifact cache dir {}: {error}",
cache_dir.display()
)
})?;
}
let path = cache_dir.join(identity.cache_filename());
let identity_json = serde_json::to_vec(identity)
.map_err(|error| format!("cannot serialize matcher artifact identity: {error}"))?;
let identity_digest = identity.digest();
let content_digest = sections.content_digest();
let identity_len = u32::try_from(identity_json.len())
.map_err(|_| "matcher artifact identity exceeds u32 length".to_owned())?;
let literal_len = u32::try_from(sections.literal_index.len())
.map_err(|_| "matcher artifact literal index exceeds u32 length".to_owned())?;
let regex_len = u32::try_from(sections.regex_programs.len())
.map_err(|_| "matcher artifact regex programs exceed u32 length".to_owned())?;
let suppression_len = u32::try_from(sections.suppression_policy.len())
.map_err(|_| "matcher artifact suppression policy exceeds u32 length".to_owned())?;
let artifact_len = checked_matcher_artifact_len(
identity_json.len(),
sections.literal_index.len(),
sections.regex_programs.len(),
sections.suppression_policy.len(),
)?;
atomic_write(&path, artifact_len, |tmp| {
tmp.write_all(MATCHER_ARTIFACT_MAGIC)?;
tmp.write_all(&MATCHER_ARTIFACT_VERSION.to_le_bytes())?;
tmp.write_all(&identity_len.to_le_bytes())?;
tmp.write_all(&identity_json)?;
tmp.write_all(&identity_digest)?;
tmp.write_all(&content_digest)?;
tmp.write_all(&literal_len.to_le_bytes())?;
tmp.write_all(§ions.literal_index)?;
tmp.write_all(®ex_len.to_le_bytes())?;
tmp.write_all(§ions.regex_programs)?;
tmp.write_all(&suppression_len.to_le_bytes())?;
tmp.write_all(§ions.suppression_policy)
})
.map_err(|error| format!("cannot write matcher artifact {}: {error}", path.display()))?;
evict_old_matcher_artifacts(cache_dir);
Ok(())
}
const MATCHER_ARTIFACT_MAX_ENTRIES: usize = 8;
fn evict_old_matcher_artifacts(cache_dir: &Path) {
let Ok(entries) = std::fs::read_dir(cache_dir) else {
return;
};
let mut artifacts = Vec::with_capacity(MATCHER_ARTIFACT_MAX_ENTRIES + 1);
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("khm") {
continue;
}
let modified = entry
.metadata()
.and_then(|meta| meta.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
artifacts.push((modified, path));
if artifacts.len() > MATCHER_ARTIFACT_MAX_ENTRIES {
let oldest = artifacts
.iter()
.enumerate()
.min_by_key(|(_, (modified, _))| *modified)
.map(|(index, _)| index)
.unwrap_or(0);
let (_, stale_path) = artifacts.swap_remove(oldest);
let _ = std::fs::remove_file(stale_path);
}
}
}
fn atomic_write(
path: &Path,
expected_len: usize,
write_body: impl FnOnce(&mut tempfile::NamedTempFile) -> std::io::Result<()>,
) -> std::io::Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"matcher artifact path has no parent directory",
)
})?;
std::fs::create_dir_all(parent)?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
write_body(&mut tmp)?;
let actual_len = usize::try_from(tmp.as_file().metadata()?.len()).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"matcher artifact length exceeds usize",
)
})?;
if actual_len != expected_len {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("matcher artifact writer produced {actual_len} bytes, expected {expected_len}"),
));
}
tmp.as_file().sync_all()?;
tmp.persist(path).map(|_| ()).map_err(|error| error.error)
}
fn record_outcome(outcome: &MatcherArtifactCacheOutcome) {
match outcome {
MatcherArtifactCacheOutcome::Hit => {
keyhog_profile::record_cache_hit(keyhog_profile::CacheId::MatcherArtifact);
}
MatcherArtifactCacheOutcome::Miss | MatcherArtifactCacheOutcome::Invalidated { .. } => {
keyhog_profile::record_cache_miss(keyhog_profile::CacheId::MatcherArtifact);
}
MatcherArtifactCacheOutcome::Disabled => {}
}
}
pub fn compile_shared_with_matcher_artifact_cache(
detectors: Arc<[keyhog_core::DetectorSpec]>,
gpu_policy: GpuInitPolicy,
tuning_config: &ScannerTuningConfig,
resolved_config_digest: [u8; 32],
pack_generation: Option<&str>,
runtime_identity: Option<&str>,
) -> Result<(CompiledScanner, MatcherArtifactCacheOutcome)> {
let cache_dir = configured_matcher_artifact_cache_dir();
let Some(backend) = matcher_backend_for_gpu_policy(gpu_policy) else {
return compile_without_matcher_artifact_cache(
normalize_detectors_for_matcher_compile(detectors),
gpu_policy,
tuning_config,
);
};
if cache_dir.is_none() {
return compile_without_matcher_artifact_cache(
normalize_detectors_for_matcher_compile(detectors),
gpu_policy,
tuning_config,
);
}
let ir = match CanonicalDetectorExecutionIr::compile(detectors.as_ref()) {
Ok(ir) => ir,
Err(error) => {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact cache unavailable ({error}); compiling without cache"
);
return compile_with_matcher_artifact_outcome(
normalize_detectors_for_matcher_compile(detectors),
gpu_policy,
tuning_config,
MatcherArtifactCacheOutcome::Miss,
);
}
};
let detector_digest = ir.digest();
let sorted: Arc<[keyhog_core::DetectorSpec]> = ir.detectors().to_vec().into();
let identity = match MatcherArtifactIdentity::new(
detector_digest,
resolved_config_digest,
pack_generation,
backend,
runtime_identity,
) {
Ok(identity) => identity,
Err(error) => {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact cache unavailable ({error}); compiling without cache"
);
return compile_with_matcher_artifact_outcome(
sorted,
gpu_policy,
tuning_config,
MatcherArtifactCacheOutcome::Miss,
);
}
};
let Some(cache_dir) = cache_dir.as_ref() else {
return compile_without_matcher_artifact_cache(sorted, gpu_policy, tuning_config);
};
let path = cache_dir.join(identity.cache_filename());
let mut allow_store = true;
let rebuild_outcome = match load_borrowed_matcher_artifact(cache_dir, &identity) {
Ok(loaded) => {
let (literal_index, regex_programs, suppression_policy) = loaded.section_bytes();
match hydrate_matcher_artifact_bytes(
loaded.ranges.backend,
literal_index,
regex_programs,
suppression_policy,
detector_digest,
sorted.as_ref(),
) {
Ok(state) => {
match CompiledScanner::compile_shared_from_compile_state(
Arc::clone(&sorted),
gpu_policy,
tuning_config,
state,
) {
Ok(scanner) => {
let outcome = MatcherArtifactCacheOutcome::Hit;
record_outcome(&outcome);
return Ok((scanner, outcome));
}
Err(error) => {
let reason = format!("compile from hydrated state failed: {error}");
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact hit unusable ({}); removing entry {} and rebuilding",
error,
path.display()
);
if let Err(remove_error) = std::fs::remove_file(&path) {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"failed to remove unusable matcher artifact entry {}: {}",
path.display(),
remove_error
);
}
allow_store = false;
MatcherArtifactCacheOutcome::Invalidated { reason }
}
}
}
Err(error) => {
let reason = format!("hydrate failed: {error}");
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact hydrate failed ({}); removing entry {} and rebuilding",
error,
path.display()
);
if let Err(remove_error) = std::fs::remove_file(&path) {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"failed to remove unusable matcher artifact entry {}: {}",
path.display(),
remove_error
);
}
allow_store = false;
MatcherArtifactCacheOutcome::Invalidated { reason }
}
}
}
Err(reason) => {
let outcome = if path.exists() {
MatcherArtifactCacheOutcome::Invalidated {
reason: reason.clone(),
}
} else {
MatcherArtifactCacheOutcome::Miss
};
tracing::debug!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact cache miss ({}); outcome={}",
reason,
outcome.as_str()
);
outcome
}
};
let (sections, state) = match CompiledRouteMatcherSections::compile_with_state(&ir, backend) {
Ok(pair) => pair,
Err(error) => {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact section compile failed ({}); compiling without cache",
error
);
return compile_with_matcher_artifact_outcome(
sorted,
gpu_policy,
tuning_config,
MatcherArtifactCacheOutcome::Miss,
);
}
};
if allow_store {
match hydrate_matcher_artifact_state(§ions, detector_digest, sorted.as_ref()) {
Ok(_) => {
if let Err(store_error) = store_matcher_artifact(cache_dir, &identity, §ions) {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"failed to persist matcher artifact cache entry: {}",
store_error
);
}
}
Err(error) => {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"skipping matcher artifact persist; freshly compiled envelopes fail hydrate ({}): {}",
path.display(),
error
);
}
}
} else {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"skipping matcher artifact rewrite after deterministic reuse failure for {}",
path.display()
);
}
match CompiledScanner::compile_shared_from_compile_state(
Arc::clone(&sorted),
gpu_policy,
tuning_config,
state,
) {
Ok(scanner) => {
record_outcome(&rebuild_outcome);
Ok((scanner, rebuild_outcome))
}
Err(error) => {
tracing::warn!(
target: "keyhog::matcher_artifact_cache",
"matcher artifact rebuild compile failed ({}); compiling without cache",
error
);
compile_with_matcher_artifact_outcome(
sorted,
gpu_policy,
tuning_config,
rebuild_outcome,
)
}
}
}
fn normalize_detectors_for_matcher_compile(
detectors: Arc<[keyhog_core::DetectorSpec]>,
) -> Arc<[keyhog_core::DetectorSpec]> {
let mut normalized = detectors.to_vec();
normalized.sort_unstable_by(|left, right| left.id.cmp(&right.id));
for detector in &mut normalized {
detector.tests.clear();
}
normalized.into()
}
fn compile_without_matcher_artifact_cache(
detectors: Arc<[keyhog_core::DetectorSpec]>,
gpu_policy: GpuInitPolicy,
tuning_config: &ScannerTuningConfig,
) -> Result<(CompiledScanner, MatcherArtifactCacheOutcome)> {
compile_with_matcher_artifact_outcome(
detectors,
gpu_policy,
tuning_config,
MatcherArtifactCacheOutcome::Disabled,
)
}
fn compile_with_matcher_artifact_outcome(
detectors: Arc<[keyhog_core::DetectorSpec]>,
gpu_policy: GpuInitPolicy,
tuning_config: &ScannerTuningConfig,
outcome: MatcherArtifactCacheOutcome,
) -> Result<(CompiledScanner, MatcherArtifactCacheOutcome)> {
record_outcome(&outcome);
let scanner = CompiledScanner::compile_shared_with_gpu_policy_and_tuning(
detectors,
gpu_policy,
tuning_config,
)?;
Ok((scanner, outcome))
}
fn hydrate_matcher_artifact_state(
sections: &CompiledRouteMatcherSections,
detector_digest: [u8; 32],
detectors: &[keyhog_core::DetectorSpec],
) -> Result<CompileState> {
hydrate_matcher_artifact_bytes(
sections.backend,
§ions.literal_index,
§ions.regex_programs,
§ions.suppression_policy,
detector_digest,
detectors,
)
}
fn hydrate_matcher_artifact_bytes(
backend: ExecutionPackBackend,
literal_index: &[u8],
regex_programs: &[u8],
suppression_policy: &[u8],
detector_digest: [u8; 32],
detectors: &[keyhog_core::DetectorSpec],
) -> Result<CompileState> {
decode_local_matcher_artifact_compile_state_sections(
backend,
literal_index,
regex_programs,
suppression_policy,
detector_digest,
detectors,
)
.map_err(|error| ScanError::Config(error.to_string()))
}
#[cfg(test)]
#[path = "../tests/unit/matcher_artifact_cache_inline.rs"]
mod tests;