use serde::{Deserialize, Serialize};
use std::path::Path;
use super::scanner::find_first_match;
use super::version::extract_version;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionRule {
pub name: String,
pub detect: Vec<DetectionPattern>,
#[serde(default)]
pub version_from: Option<VersionSource>,
#[serde(default)]
pub suggests: Vec<String>,
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: String,
pub version: Option<String>,
pub source: String,
pub suggests: Vec<String>,
pub category: ToolCategory,
}
pub fn run(project_dir: &Path, rules: &[DetectionRule]) -> Vec<Detection> {
let mut out = Vec::new();
for rule in rules {
if let Some(matched_source) = rule_match_source(project_dir, 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, rule: &DetectionRule) -> Option<String> {
for pattern in &rule.detect {
match pattern {
DetectionPattern::File { file } => {
if let Some(p) = find_first_match(project_dir, 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) = find_first_match(project_dir, 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;
let mut file = std::fs::File::open(path)?;
let mut buf = vec![0u8; cap];
let n = file.read(&mut buf)?;
buf.truncate(n);
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
.chars()
.all(|c| (c.is_ascii_graphic() && c != '"' && c != '\\') || c == ' ')
{
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!["cargo-watch".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!["pnpm".into(), "yarn".into()],
category: ToolCategory::Runtime,
},
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![],
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: "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: "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;
#[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(), &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(), &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());
}
}