use std::path::Path;
#[derive(Debug, Clone)]
pub struct EcosystemDef {
pub name: &'static str,
pub star_file: &'static str,
pub binaries: &'static [&'static str],
pub markers: &'static [&'static str],
pub dir_markers: &'static [&'static str],
pub glob_markers: &'static [&'static str],
pub safe_sandbox: Option<&'static str>,
pub full_sandbox: &'static str,
}
pub const ECOSYSTEMS: &[EcosystemDef] = &[
EcosystemDef {
name: "git",
star_file: "sandboxes.star",
binaries: &["git"],
markers: &[],
dir_markers: &[".git"],
glob_markers: &[],
safe_sandbox: Some("git_safe"),
full_sandbox: "git_full",
},
EcosystemDef {
name: "rust",
star_file: "rust.star",
binaries: &["cargo", "rustc", "rustup"],
markers: &["Cargo.toml"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: Some("rust_safe"),
full_sandbox: "rust_full",
},
EcosystemDef {
name: "go",
star_file: "go.star",
binaries: &["go"],
markers: &["go.mod"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: Some("go_safe"),
full_sandbox: "go_full",
},
EcosystemDef {
name: "node",
star_file: "node.star",
binaries: &["node", "npm", "npx", "bun", "deno", "yarn", "pnpm"],
markers: &["package.json"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: None,
full_sandbox: "node_full",
},
EcosystemDef {
name: "python",
star_file: "python.star",
binaries: &["python", "python3", "pip", "pip3", "uv", "poetry"],
markers: &["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: None,
full_sandbox: "python_full",
},
EcosystemDef {
name: "ruby",
star_file: "ruby.star",
binaries: &["ruby", "gem", "bundle", "rails"],
markers: &["Gemfile"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: None,
full_sandbox: "ruby_full",
},
EcosystemDef {
name: "java",
star_file: "java.star",
binaries: &["gradle", "gradlew", "mvn", "mvnw", "java", "javac"],
markers: &["build.gradle", "pom.xml", "build.gradle.kts"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: None,
full_sandbox: "java_full",
},
EcosystemDef {
name: "docker",
star_file: "docker.star",
binaries: &["docker", "docker-compose", "podman"],
markers: &["Dockerfile", "docker-compose.yml", "compose.yml"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: Some("docker_safe"),
full_sandbox: "docker_full",
},
EcosystemDef {
name: "swift",
star_file: "swift.star",
binaries: &["swift", "swiftc", "xcodebuild"],
markers: &["Package.swift"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: None,
full_sandbox: "swift_full",
},
EcosystemDef {
name: "dotnet",
star_file: "dotnet.star",
binaries: &["dotnet", "msbuild"],
markers: &[],
dir_markers: &[],
glob_markers: &["*.csproj", "*.sln", "*.fsproj"],
safe_sandbox: None,
full_sandbox: "dotnet_full",
},
EcosystemDef {
name: "make",
star_file: "make.star",
binaries: &["make", "cmake", "just"],
markers: &["Makefile", "CMakeLists.txt", "justfile"],
dir_markers: &[],
glob_markers: &[],
safe_sandbox: None,
full_sandbox: "make_full",
},
];
pub fn ecosystem_for_binary(binary: &str) -> Option<&'static str> {
ECOSYSTEMS
.iter()
.find(|e| e.binaries.contains(&binary))
.map(|e| e.name)
}
pub fn detect_ecosystems(
project_dir: &Path,
observed_binaries: &[&str],
) -> Vec<&'static EcosystemDef> {
let mut seen = std::collections::BTreeSet::new();
let mut result = Vec::new();
for eco in ECOSYSTEMS {
if seen.contains(eco.name) {
continue;
}
let matched = eco.markers.iter().any(|m| project_dir.join(m).exists())
|| eco.dir_markers.iter().any(|m| project_dir.join(m).is_dir())
|| has_glob_match(project_dir, eco.glob_markers)
|| eco.binaries.iter().any(|b| observed_binaries.contains(b));
if matched {
seen.insert(eco.name);
result.push(eco);
}
}
result
}
fn has_glob_match(dir: &Path, patterns: &[&str]) -> bool {
if patterns.is_empty() {
return false;
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return false,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
for pattern in patterns {
if let Some(ext) = pattern.strip_prefix("*.") {
if name.ends_with(ext) {
return true;
}
}
}
}
false
}
pub fn generate_policy(ecosystems: &[&'static EcosystemDef]) -> String {
use crate::policy_gen::spec::PolicySpec;
PolicySpec::from_ecosystems(ecosystems).to_starlark()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_rust_by_marker() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let detected = detect_ecosystems(tmp.path(), &[]);
assert!(detected.iter().any(|e| e.name == "rust"));
}
#[test]
fn detect_go_by_marker() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("go.mod"), "").unwrap();
let detected = detect_ecosystems(tmp.path(), &[]);
assert!(detected.iter().any(|e| e.name == "go"));
}
#[test]
fn detect_by_binary() {
let tmp = tempfile::tempdir().unwrap();
let detected = detect_ecosystems(tmp.path(), &["cargo", "docker"]);
assert!(detected.iter().any(|e| e.name == "rust"));
assert!(detected.iter().any(|e| e.name == "docker"));
}
#[test]
fn detect_git_by_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let detected = detect_ecosystems(tmp.path(), &[]);
assert!(detected.iter().any(|e| e.name == "git"));
}
#[test]
fn detect_deduplicates() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let detected = detect_ecosystems(tmp.path(), &["cargo"]);
let rust_count = detected.iter().filter(|e| e.name == "rust").count();
assert_eq!(rust_count, 1);
}
#[test]
fn detect_dotnet_by_glob() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("MyApp.csproj"), "").unwrap();
let detected = detect_ecosystems(tmp.path(), &[]);
assert!(detected.iter().any(|e| e.name == "dotnet"));
}
#[test]
fn binary_to_ecosystem_mapping() {
assert_eq!(ecosystem_for_binary("cargo"), Some("rust"));
assert_eq!(ecosystem_for_binary("npm"), Some("node"));
assert_eq!(ecosystem_for_binary("python3"), Some("python"));
assert_eq!(ecosystem_for_binary("unknown_tool"), None);
}
#[test]
fn generate_policy_for_detected_ecosystems() {
let ecosystems: Vec<&EcosystemDef> = ECOSYSTEMS
.iter()
.filter(|e| e.name == "git" || e.name == "rust")
.collect();
let starlark = generate_policy(&ecosystems);
assert!(
starlark.contains("@clash//sandboxes.star"),
"missing sandboxes load:\n{starlark}"
);
assert!(
starlark.contains("@clash//rust.star"),
"missing rust load:\n{starlark}"
);
assert!(
starlark.contains("git_safe"),
"missing git_safe:\n{starlark}"
);
assert!(
starlark.contains("git_full"),
"missing git_full:\n{starlark}"
);
assert!(
starlark.contains("rust_safe"),
"missing rust_safe:\n{starlark}"
);
assert!(
starlark.contains("rust_full"),
"missing rust_full:\n{starlark}"
);
let output = clash_starlark::evaluate(&starlark, "<test>", std::path::Path::new("."))
.expect("generated policy must evaluate");
crate::policy::compile::compile_to_tree(&output.json)
.expect("generated policy must compile");
}
#[test]
fn generate_policy_single_variant_ecosystem() {
let ecosystems: Vec<&EcosystemDef> =
ECOSYSTEMS.iter().filter(|e| e.name == "node").collect();
let starlark = generate_policy(&ecosystems);
assert!(
starlark.contains("node_full"),
"missing node_full:\n{starlark}"
);
assert!(
!starlark.contains("node_safe"),
"node_safe should not exist:\n{starlark}"
);
let output = clash_starlark::evaluate(&starlark, "<test>", std::path::Path::new("."))
.expect("generated policy must evaluate");
crate::policy::compile::compile_to_tree(&output.json)
.expect("generated policy must compile");
}
#[test]
fn generate_policy_empty_ecosystems() {
let ecosystems: Vec<&EcosystemDef> = vec![];
let starlark = generate_policy(&ecosystems);
let output = clash_starlark::evaluate(&starlark, "<test>", std::path::Path::new("."))
.expect("empty policy must evaluate");
crate::policy::compile::compile_to_tree(&output.json).expect("empty policy must compile");
}
}