pub mod js;
pub mod python;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetClass {
Host,
Llm,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct CallSite {
pub file: String,
pub line: u32,
pub callee: String,
pub function: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Sighting {
pub file: String,
pub line: u32,
}
impl Sighting {
pub fn label(&self) -> String {
format!("{}:{}", self.file, self.line)
}
}
#[derive(Debug, Clone)]
pub struct TargetEvidence {
pub class: TargetClass,
pub sightings: BTreeSet<Sighting>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FunctionFacts {
pub entrypoint: String,
pub file: String,
pub line: u32,
pub effects: u32,
pub idempotent_unsafe: u32,
pub time_reads: u32,
pub random_reads: u32,
pub unsafe_reasons: Vec<String>,
pub targets: BTreeSet<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
pub files_scanned: usize,
pub python_available: bool,
pub targets: BTreeMap<String, TargetEvidence>,
pub libs: BTreeSet<String>,
pub functions: Vec<FunctionFacts>,
pub resilience_libs: BTreeSet<String>,
}
impl ScanResult {
fn add(&mut self, target: String, class: TargetClass, file: String, line: u32) {
self.targets
.entry(target)
.or_insert_with(|| TargetEvidence {
class,
sightings: BTreeSet::new(),
})
.sightings
.insert(Sighting { file, line });
}
}
pub fn scan(project: &Path) -> ScanResult {
let mut result = ScanResult::default();
let py = python::scan(project);
result.python_available = py.available;
result.files_scanned += py.files_scanned;
merge_lang(&mut result, &py.findings);
result.functions.extend(py.functions);
let js = js::scan(project);
result.files_scanned += js.files_scanned;
merge_lang(&mut result, &js.findings);
result.functions.extend(js.functions);
result
.functions
.sort_by(|a, b| (&a.file, a.line, &a.entrypoint).cmp(&(&b.file, b.line, &b.entrypoint)));
result
}
#[derive(Debug, Clone, Default)]
pub struct LangFindings {
pub llm: Vec<(String, Sighting)>,
pub hosts: Vec<(String, Sighting)>,
pub http_in_use: bool,
pub libs: BTreeSet<String>,
pub call_sites: Vec<CallSite>,
pub resilience_libs: BTreeSet<String>,
}
fn merge_lang(result: &mut ScanResult, f: &LangFindings) {
for (provider, s) in &f.llm {
result.add(
format!("llm:{provider}"),
TargetClass::Llm,
s.file.clone(),
s.line,
);
}
if f.http_in_use {
for (host, s) in &f.hosts {
result.add(host.clone(), TargetClass::Host, s.file.clone(), s.line);
}
}
for lib in &f.libs {
result.libs.insert(lib.clone());
}
for lib in &f.resilience_libs {
result.resilience_libs.insert(lib.clone());
}
}
pub(crate) const SKIP_DIRS: &[&str] = &[
".keel",
".git",
".hg",
".svn",
"__pycache__",
"node_modules",
".venv",
"venv",
".mypy_cache",
".pytest_cache",
"dist",
"build",
"target",
];
pub(crate) fn collect_files(dir: &Path, extensions: &[&str], out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') {
continue;
}
collect_files(&path, extensions, out);
} else if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| extensions.contains(&e))
{
out.push(path);
}
}
}
pub(crate) fn host_from_url(s: &str) -> Option<String> {
let s = s.trim();
let (scheme, rest) = s.split_once("://")?;
if scheme.is_empty()
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
|| !scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
return None;
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host_port = authority.rsplit('@').next().unwrap_or(authority);
let host = host_port.split(':').next().unwrap_or(host_port);
if host.is_empty() || host.contains(|c: char| c.is_whitespace()) {
return None;
}
Some(host.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_extraction_strips_port_userinfo_and_path() {
assert_eq!(
host_from_url("https://api.stripe.com/v1/x").as_deref(),
Some("api.stripe.com")
);
assert_eq!(
host_from_url("postgres://u:p@db.internal:5432/app").as_deref(),
Some("db.internal")
);
assert_eq!(host_from_url("HTTPS://API.X").as_deref(), Some("api.x"));
assert_eq!(host_from_url("not a url"), None);
assert_eq!(host_from_url("://nohost"), None);
assert_eq!(host_from_url("1bad://x"), None);
}
}