use serde::{Deserialize, Serialize};
use std::path::Path;
use super::version::extract_version;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionRule {
pub name: std::borrow::Cow<'static, str>,
pub detect: Vec<DetectionPattern>,
#[serde(default)]
pub version_from: Option<VersionSource>,
#[serde(default)]
pub suggests: Vec<std::borrow::Cow<'static, str>>,
pub category: ToolCategory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DetectionPattern {
File {
file: String,
},
Dir {
dir: String,
},
FileContaining {
file: String,
containing: String,
},
}
pub const MAX_CONTAINING_BYTES: usize = 4 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionSource {
pub file: String,
#[serde(default)]
pub pattern: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolCategory {
Runtime,
Build,
Dev,
Ops,
}
#[derive(Debug, Clone, Serialize)]
pub struct Detection {
pub tool: std::borrow::Cow<'static, str>,
pub version: Option<String>,
pub source: String,
pub suggests: Vec<std::borrow::Cow<'static, str>>,
pub category: ToolCategory,
}
pub fn run(project_dir: &Path, rules: &[DetectionRule]) -> Vec<Detection> {
let index = super::scanner::RootIndex::build(project_dir);
let mut out = Vec::new();
for rule in rules {
if let Some(matched_source) = rule_match_source(project_dir, &index, rule) {
let version = rule
.version_from
.as_ref()
.and_then(|vs| extract_version(project_dir, vs));
out.push(Detection {
tool: rule.name.clone(),
version,
source: matched_source,
suggests: rule.suggests.clone(),
category: rule.category,
});
}
}
out
}
fn rule_match_source(
project_dir: &Path,
index: &super::scanner::RootIndex,
rule: &DetectionRule,
) -> Option<String> {
for pattern in &rule.detect {
match pattern {
DetectionPattern::File { file } => {
if let Some(p) = index.find_first_match(file) {
let name = p.file_name()?.to_string_lossy().into_owned();
if let Some(safe) = sanitize_source(&name) {
return Some(safe);
}
continue;
}
}
DetectionPattern::Dir { dir } => {
if project_dir.join(dir).is_dir() {
if let Some(safe) = sanitize_source(dir) {
return Some(safe);
}
}
}
DetectionPattern::FileContaining { file, containing } => {
if let Some(p) = index.find_first_match(file) {
if let Ok(content) = read_bounded(p, MAX_CONTAINING_BYTES) {
if content.contains(containing) {
let name = p.file_name()?.to_string_lossy().into_owned();
if let Some(safe) = sanitize_source(&name) {
return Some(safe);
}
}
}
}
}
}
}
None
}
fn read_bounded(path: &Path, cap: usize) -> std::io::Result<String> {
use std::io::Read;
thread_local! {
static BUF: std::cell::RefCell<Vec<u8>> =
std::cell::RefCell::new(Vec::with_capacity(MAX_CONTAINING_BYTES));
}
let file = std::fs::File::open(path)?;
BUF.with(|cell| {
let mut buf = cell.borrow_mut();
buf.clear();
buf.reserve(cap);
file.take(cap as u64).read_to_end(&mut buf)?;
Ok(String::from_utf8_lossy(&buf).into_owned())
})
}
fn sanitize_source(name: &str) -> Option<String> {
if name.is_empty() || name.len() > 255 {
return None;
}
if !name
.as_bytes()
.iter()
.all(|&b| (b.is_ascii_graphic() && b != b'"' && b != b'\\') || b == b' ')
{
return None;
}
Some(name.to_string())
}
pub fn default_rules() -> &'static [DetectionRule] {
use std::sync::OnceLock;
static RULES: OnceLock<Vec<DetectionRule>> = OnceLock::new();
RULES.get_or_init(build_default_rules)
}
fn build_default_rules() -> Vec<DetectionRule> {
vec![
DetectionRule {
name: "rust".into(),
detect: vec![
DetectionPattern::File {
file: "Cargo.toml".into(),
},
DetectionPattern::File {
file: "Cargo.lock".into(),
},
DetectionPattern::File {
file: "rust-toolchain.toml".into(),
},
DetectionPattern::File {
file: "rust-toolchain".into(),
},
],
version_from: Some(VersionSource {
file: "rust-toolchain.toml".into(),
pattern: Some(r#"channel\s*=\s*"([^"]+)""#.into()),
}),
suggests: vec!["bacon".into(), "cargo-nextest".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "node".into(),
detect: vec![
DetectionPattern::File {
file: "package.json".into(),
},
DetectionPattern::File {
file: "package-lock.json".into(),
},
DetectionPattern::File {
file: "yarn.lock".into(),
},
DetectionPattern::File {
file: "pnpm-lock.yaml".into(),
},
DetectionPattern::File {
file: ".nvmrc".into(),
},
],
version_from: Some(VersionSource {
file: ".nvmrc".into(),
pattern: Some(r"v?(\d+(?:\.\d+(?:\.\d+)?)?)".into()),
}),
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "pnpm".into(),
detect: vec![DetectionPattern::File {
file: "pnpm-lock.yaml".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "yarn".into(),
detect: vec![DetectionPattern::File {
file: "yarn.lock".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "bun".into(),
detect: vec![
DetectionPattern::File {
file: "bun.lockb".into(),
},
DetectionPattern::File {
file: "bun.lock".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "php".into(),
detect: vec![
DetectionPattern::File {
file: "composer.json".into(),
},
DetectionPattern::File {
file: "composer.lock".into(),
},
DetectionPattern::File {
file: "artisan".into(),
},
DetectionPattern::File {
file: "symfony.lock".into(),
},
],
version_from: None,
suggests: vec!["composer".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "composer".into(),
detect: vec![
DetectionPattern::File {
file: "composer.json".into(),
},
DetectionPattern::File {
file: "composer.lock".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "python".into(),
detect: vec![
DetectionPattern::File {
file: "pyproject.toml".into(),
},
DetectionPattern::File {
file: "requirements.txt".into(),
},
DetectionPattern::File {
file: "Pipfile".into(),
},
DetectionPattern::File {
file: "setup.py".into(),
},
DetectionPattern::File {
file: ".python-version".into(),
},
],
version_from: Some(VersionSource {
file: ".python-version".into(),
pattern: None,
}),
suggests: vec!["uv".into(), "poetry".into(), "pipx".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "go".into(),
detect: vec![
DetectionPattern::File {
file: "go.mod".into(),
},
DetectionPattern::File {
file: "go.sum".into(),
},
],
version_from: Some(VersionSource {
file: "go.mod".into(),
pattern: Some(r"^go\s+(\d+\.\d+(?:\.\d+)?)".into()),
}),
suggests: vec!["golangci-lint".into(), "air".into(), "delve".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "ruby".into(),
detect: vec![
DetectionPattern::File {
file: "Gemfile".into(),
},
DetectionPattern::File {
file: "Gemfile.lock".into(),
},
DetectionPattern::File {
file: ".ruby-version".into(),
},
],
version_from: Some(VersionSource {
file: ".ruby-version".into(),
pattern: None,
}),
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "deno".into(),
detect: vec![
DetectionPattern::File {
file: "deno.json".into(),
},
DetectionPattern::File {
file: "deno.jsonc".into(),
},
DetectionPattern::File {
file: "deno.lock".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "elixir".into(),
detect: vec![
DetectionPattern::File {
file: "mix.exs".into(),
},
DetectionPattern::File {
file: "mix.lock".into(),
},
],
version_from: None,
suggests: vec!["erlang".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "erlang".into(),
detect: vec![
DetectionPattern::File {
file: "rebar.config".into(),
},
DetectionPattern::File {
file: "rebar3.config".into(),
},
DetectionPattern::File {
file: "erlang.mk".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "haskell".into(),
detect: vec![
DetectionPattern::File {
file: "cabal.project".into(),
},
DetectionPattern::File {
file: "stack.yaml".into(),
},
DetectionPattern::File {
file: "package.yaml".into(),
},
DetectionPattern::File {
file: "*.cabal".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "crystal".into(),
detect: vec![
DetectionPattern::File {
file: "shard.yml".into(),
},
DetectionPattern::File {
file: "shard.lock".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "gleam".into(),
detect: vec![DetectionPattern::File {
file: "gleam.toml".into(),
}],
version_from: None,
suggests: vec!["erlang".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "lua".into(),
detect: vec![DetectionPattern::File {
file: ".lua-version".into(),
}],
version_from: Some(VersionSource {
file: ".lua-version".into(),
pattern: None,
}),
suggests: vec!["luarocks".into()],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "luarocks".into(),
detect: vec![DetectionPattern::File {
file: "*.rockspec".into(),
}],
version_from: None,
suggests: vec!["lua".into()],
category: ToolCategory::Build,
},
DetectionRule {
name: "nim".into(),
detect: vec![
DetectionPattern::File {
file: "*.nimble".into(),
},
DetectionPattern::File {
file: "nim.cfg".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "ocaml".into(),
detect: vec![
DetectionPattern::File {
file: "dune-project".into(),
},
DetectionPattern::File {
file: "*.opam".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "scala".into(),
detect: vec![
DetectionPattern::File {
file: "build.sbt".into(),
},
DetectionPattern::File {
file: "build.sc".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "zig".into(),
detect: vec![
DetectionPattern::File {
file: "build.zig".into(),
},
DetectionPattern::File {
file: "build.zig.zon".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "julia".into(),
detect: vec![
DetectionPattern::File {
file: "Manifest.toml".into(),
},
DetectionPattern::File {
file: "JuliaProject.toml".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
DetectionRule {
name: "cmake".into(),
detect: vec![DetectionPattern::File {
file: "CMakeLists.txt".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "skaffold".into(),
detect: vec![DetectionPattern::File {
file: "skaffold.yaml".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Dev,
},
DetectionRule {
name: "bazelisk".into(),
detect: vec![
DetectionPattern::File {
file: "MODULE.bazel".into(),
},
DetectionPattern::File {
file: "WORKSPACE".into(),
},
DetectionPattern::File {
file: "WORKSPACE.bazel".into(),
},
DetectionPattern::File {
file: "BUILD.bazel".into(),
},
DetectionPattern::File {
file: ".bazelrc".into(),
},
DetectionPattern::File {
file: ".bazelversion".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "docker".into(),
detect: vec![
DetectionPattern::File {
file: "Dockerfile".into(),
},
DetectionPattern::File {
file: "docker-compose.yml".into(),
},
DetectionPattern::File {
file: "docker-compose.yaml".into(),
},
DetectionPattern::File {
file: "compose.yml".into(),
},
DetectionPattern::File {
file: "compose.yaml".into(),
},
],
version_from: None,
suggests: vec!["docker-compose".into(), "lazydocker".into()],
category: ToolCategory::Ops,
},
DetectionRule {
name: "kubectl".into(),
detect: vec![
DetectionPattern::Dir { dir: "k8s".into() },
DetectionPattern::Dir {
dir: "kubernetes".into(),
},
DetectionPattern::Dir {
dir: "manifests".into(),
},
DetectionPattern::FileContaining {
file: "*.yaml".into(),
containing: "kind: Deployment".into(),
},
DetectionPattern::FileContaining {
file: "*.yaml".into(),
containing: "apiVersion: apps/v1".into(),
},
],
version_from: None,
suggests: vec!["helm".into(), "kustomize".into(), "k9s".into()],
category: ToolCategory::Ops,
},
DetectionRule {
name: "helm".into(),
detect: vec![
DetectionPattern::File {
file: "Chart.yaml".into(),
},
DetectionPattern::Dir {
dir: "charts".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Ops,
},
DetectionRule {
name: "terraform".into(),
detect: vec![
DetectionPattern::File {
file: ".terraform.lock.hcl".into(),
},
DetectionPattern::File {
file: "main.tf".into(),
},
DetectionPattern::File {
file: "*.tf".into(),
},
],
version_from: None,
suggests: vec!["tflint".into(), "terraform-docs".into()],
category: ToolCategory::Ops,
},
DetectionRule {
name: "pre-commit".into(),
detect: vec![DetectionPattern::File {
file: ".pre-commit-config.yaml".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Dev,
},
DetectionRule {
name: "git".into(),
detect: vec![DetectionPattern::Dir { dir: ".git".into() }],
version_from: None,
suggests: vec![],
category: ToolCategory::Dev,
},
DetectionRule {
name: "gh".into(),
detect: vec![DetectionPattern::Dir {
dir: ".github".into(),
}],
version_from: None,
suggests: vec!["release-plz".into()],
category: ToolCategory::Dev,
},
DetectionRule {
name: "release-plz".into(),
detect: vec![DetectionPattern::File {
file: "release-plz.toml".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Dev,
},
DetectionRule {
name: "vscode".into(),
detect: vec![DetectionPattern::Dir {
dir: ".vscode".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Dev,
},
DetectionRule {
name: "infisical".into(),
detect: vec![
DetectionPattern::File {
file: ".infisical.json".into(),
},
DetectionPattern::File {
file: ".env.infisical".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Dev,
},
DetectionRule {
name: "make".into(),
detect: vec![
DetectionPattern::File {
file: "Makefile".into(),
},
DetectionPattern::File {
file: "makefile".into(),
},
DetectionPattern::File {
file: "GNUmakefile".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "just".into(),
detect: vec![DetectionPattern::File {
file: "Justfile".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "maven".into(),
detect: vec![DetectionPattern::File {
file: "pom.xml".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "gradle".into(),
detect: vec![
DetectionPattern::File {
file: "build.gradle".into(),
},
DetectionPattern::File {
file: "build.gradle.kts".into(),
},
DetectionPattern::File {
file: "settings.gradle".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Build,
},
DetectionRule {
name: "dotnet".into(),
detect: vec![
DetectionPattern::File {
file: "*.csproj".into(),
},
DetectionPattern::File {
file: "*.fsproj".into(),
},
DetectionPattern::File {
file: "global.json".into(),
},
],
version_from: None,
suggests: vec![],
category: ToolCategory::Runtime,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[cfg(unix)]
#[test]
fn rejects_hostile_glob_match_filename() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("x.tf\n[packages]\nbad = true\n.tf"), "").unwrap();
let rule = DetectionRule {
name: "terraform".into(),
detect: vec![DetectionPattern::File {
file: "*.tf".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Ops,
};
assert!(
rule_match_source(
tmp.path(),
&super::super::scanner::RootIndex::build(tmp.path()),
&rule
)
.is_none()
);
}
#[test]
fn accepts_well_formed_filename_glob() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("main.tf"), "").unwrap();
let rule = DetectionRule {
name: "terraform".into(),
detect: vec![DetectionPattern::File {
file: "*.tf".into(),
}],
version_from: None,
suggests: vec![],
category: ToolCategory::Ops,
};
assert_eq!(
rule_match_source(
tmp.path(),
&super::super::scanner::RootIndex::build(tmp.path()),
&rule
)
.as_deref(),
Some("main.tf")
);
}
#[test]
fn sanitize_source_table() {
assert_eq!(sanitize_source("Cargo.toml").as_deref(), Some("Cargo.toml"));
assert_eq!(sanitize_source("k8s").as_deref(), Some("k8s"));
assert!(sanitize_source("").is_none());
assert!(sanitize_source("x\nbad").is_none());
assert!(sanitize_source("has\"quote").is_none());
assert!(sanitize_source("back\\slash").is_none());
assert!(sanitize_source("nul\0byte").is_none());
assert!(sanitize_source(&"x".repeat(256)).is_none());
}
#[test]
fn default_rules_name_and_suggests_are_all_borrowed() {
use std::borrow::Cow;
for rule in default_rules() {
assert!(
matches!(rule.name, Cow::Borrowed(_)),
"rule `{}` name is Cow::Owned — the Cow refactor's borrowed \
path exists precisely so built-in rules skip the per-pass \
String clone. Construct via `\"literal\".into()` from an \
&'static str, not `String::from(...).into()`.",
rule.name
);
for s in &rule.suggests {
assert!(
matches!(s, Cow::Borrowed(_)),
"suggest `{s}` on rule `{}` is Cow::Owned — same \
rationale as the name field.",
rule.name
);
}
}
}
}