use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[path = "src/detector_file_io.rs"]
mod detector_file_io;
#[derive(serde::Deserialize)]
struct ConfigKeywords {
known_prefixes: Vec<String>,
secret_keywords: Vec<String>,
test_keywords: Vec<String>,
placeholder_keywords: Vec<String>,
}
#[derive(serde::Deserialize)]
struct DecoderNames {
decoder_names: Vec<String>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest_dir = env::var("CARGO_MANIFEST_DIR")
.map_err(|error| io::Error::other(format!("CARGO_MANIFEST_DIR is not set: {error}")))?;
let out_dir = env::var("OUT_DIR")
.map_err(|error| io::Error::other(format!("OUT_DIR is not set: {error}")))?;
let output_path = Path::new(&out_dir).join("embedded_detectors.rs");
stamp_git_hash(Path::new(&manifest_dir));
let manifest_dir = Path::new(&manifest_dir);
let mut candidates = vec![manifest_dir.join("detectors")];
if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) {
candidates.push(workspace_root.join("detectors"));
}
let detectors_dir = candidates
.iter()
.find(|path| path.exists() && path.is_dir());
let Some(detectors_dir) = detectors_dir else {
let searched = candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"detectors/ directory not found; searched: {searched}. Fix: build from the keyhog workspace, keep crates/core/detectors pointed at ../../detectors, or package the detector TOMLs with keyhog-core"
),
)
.into());
};
let toml_paths = detector_toml_paths(detectors_dir)?;
if toml_paths.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"detectors directory '{}' contains no .toml files. Fix: add detector TOML files or remove the empty directory",
detectors_dir.display()
),
)
.into());
}
let entries = read_detector_entries(&toml_paths)?;
let config_keywords = read_toml::<ConfigKeywords>(
&manifest_dir.join("rules/config-keywords.toml"),
"Tier-B config keyword defaults",
)?;
validate_nonempty_unique("known_prefixes", &config_keywords.known_prefixes)?;
validate_nonempty_unique("secret_keywords", &config_keywords.secret_keywords)?;
validate_nonempty_unique("test_keywords", &config_keywords.test_keywords)?;
validate_nonempty_unique(
"placeholder_keywords",
&config_keywords.placeholder_keywords,
)?;
let decoder_names = read_toml::<DecoderNames>(
&manifest_dir.join("rules/decoder-source-suffixes.toml"),
"Tier-B decoder source suffixes",
)?;
validate_nonempty_unique("decoder_names", &decoder_names.decoder_names)?;
let detector_digest = detector_set_digest(&entries);
println!("cargo:rustc-env=KEYHOG_DETECTOR_DIGEST={detector_digest}");
write_embedded_data(
&output_path,
&entries,
&config_keywords,
&decoder_names.decoder_names,
)?;
println!("cargo:rerun-if-changed={}", detectors_dir.display());
for path in &toml_paths {
println!("cargo:rerun-if-changed={}", path.display());
}
println!(
"cargo:rerun-if-changed={}",
manifest_dir.join("rules/config-keywords.toml").display()
);
println!(
"cargo:rerun-if-changed={}",
manifest_dir
.join("rules/decoder-source-suffixes.toml")
.display()
);
println!("cargo:rerun-if-changed=src/detector_file_io.rs");
println!(
"cargo:warning=Embedded {} detectors ({} bytes)",
entries.len(),
entries
.iter()
.map(|(_, content)| content.len())
.sum::<usize>()
);
Ok(())
}
fn detector_toml_paths(detectors_dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
let read_dir = fs::read_dir(detectors_dir).map_err(|error| {
io::Error::new(
error.kind(),
format!(
"failed to read detectors directory '{}': {}. Fix: check directory permissions",
detectors_dir.display(),
error
),
)
})?;
for entry in read_dir {
let entry = entry.map_err(|error| {
io::Error::new(
error.kind(),
format!(
"failed to enumerate detectors in '{}': {}. Fix: check directory permissions",
detectors_dir.display(),
error
),
)
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
fn read_detector_entries(toml_paths: &[PathBuf]) -> io::Result<Vec<(String, String)>> {
let mut entries = Vec::with_capacity(toml_paths.len());
for path in toml_paths {
let name = file_name(path)?;
let content = detector_file_io::read_detector_toml_file(path).map_err(|error| {
io::Error::new(
error.kind(),
format!(
"failed to read detector '{}': {}. Fix: check file permissions and TOML encoding",
path.display(),
error
),
)
})?;
entries.push((name, content));
}
Ok(entries)
}
fn read_toml<T: serde::de::DeserializeOwned>(path: &Path, label: &str) -> io::Result<T> {
let raw = fs::read_to_string(path).map_err(|error| {
io::Error::new(
error.kind(),
format!("failed to read {label} '{}': {error}", path.display()),
)
})?;
toml::from_str(&raw).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid {label} '{}': {error}", path.display()),
)
})
}
fn validate_nonempty_unique(label: &str, values: &[String]) -> io::Result<()> {
if values.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{label} must not be empty"),
));
}
if let Some(value) = values.iter().find(|value| value.trim().is_empty()) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{label} contains an empty or whitespace-only value {value:?}"),
));
}
let mut seen = std::collections::HashSet::with_capacity(values.len());
if let Some(duplicate) = values.iter().find(|value| !seen.insert(value.as_str())) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{label} contains duplicate value {duplicate:?}"),
));
}
Ok(())
}
fn write_string_slice(code: &mut String, name: &str, values: &[String]) {
code.push_str(&format!("pub const {name}: &[&str] = &[\n"));
for value in values {
code.push_str(&format!(" {value:?},\n"));
}
code.push_str("];\n");
}
fn write_embedded_data(
output_path: &Path,
entries: &[(String, String)],
config_keywords: &ConfigKeywords,
decoder_names: &[String],
) -> io::Result<()> {
let mut code = String::from("pub const EMBEDDED_DETECTORS: &[(&str, &str)] = &[\n");
for (name, content) in entries {
code.push_str(&format!(" ({name:?}, {content:?}),\n"));
}
code.push_str("];\n");
write_string_slice(
&mut code,
"CONFIG_KNOWN_PREFIXES",
&config_keywords.known_prefixes,
);
write_string_slice(
&mut code,
"CONFIG_SECRET_KEYWORDS",
&config_keywords.secret_keywords,
);
write_string_slice(
&mut code,
"CONFIG_TEST_KEYWORDS",
&config_keywords.test_keywords,
);
write_string_slice(
&mut code,
"CONFIG_PLACEHOLDER_KEYWORDS",
&config_keywords.placeholder_keywords,
);
let decoder_suffixes = decoder_names
.iter()
.map(|name| format!("/{name}"))
.collect::<Vec<_>>();
write_string_slice(&mut code, "DECODER_SOURCE_SUFFIXES", &decoder_suffixes);
fs::write(output_path, code).map_err(|error| {
io::Error::new(
error.kind(),
format!(
"failed to write generated detector table '{}': {}. Fix: verify OUT_DIR is writable",
output_path.display(),
error
),
)
})
}
fn stamp_git_hash(manifest_dir: &Path) {
let workspace_root = manifest_dir
.parent()
.and_then(|p| p.parent())
.unwrap_or(manifest_dir);
let git_dir = workspace_root.join(".git");
let head_file = git_dir.join("HEAD");
println!("cargo:rerun-if-changed={}", head_file.display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("logs/HEAD").display()
);
if let Some(ref_path) = head_ref_path(&git_dir) {
println!("cargo:rerun-if-changed={}", ref_path.display());
if let Ok(relative_ref) = ref_path.strip_prefix(&git_dir) {
println!(
"cargo:rerun-if-changed={}",
git_dir.join("logs").join(relative_ref).display()
);
}
}
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
let hash = git_hash(workspace_root).unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_HASH={hash}");
}
fn head_ref_path(git_dir: &Path) -> Option<PathBuf> {
let head = fs::read_to_string(git_dir.join("HEAD")).ok()?;
let reference = head.trim().strip_prefix("ref:")?.trim();
Some(git_dir.join(reference))
}
fn git_hash(workspace_root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(workspace_root)
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let hash = String::from_utf8(output.stdout).ok()?;
let hash = hash.trim();
if hash.is_empty() {
None
} else {
Some(hash.to_string())
}
}
fn detector_set_digest(entries: &[(String, String)]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |bytes: &[u8]| {
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
};
for (name, content) in entries {
mix(name.as_bytes());
mix(&[0]);
mix(content.as_bytes());
mix(&[0]);
}
format!("{}-{hash:016x}", entries.len())
}
fn file_name(path: &Path) -> io::Result<String> {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"detector path '{}' does not have a valid UTF-8 file name. Fix: rename the detector file",
path.display()
),
)
})
}