use crate::*;
pub(crate) fn scan_builder_entry_effect(_cr: &str, path: &str) -> Option<&'static str> {
const ENTRIES: &[(&str, &str)] = &[
("duct::cmd", "Exec"),
("duct::sh", "Exec"),
("ureq::get", "Net"),
("ureq::post", "Net"),
("ureq::put", "Net"),
("ureq::delete", "Net"),
("ureq::head", "Net"),
("ureq::patch", "Net"),
("ureq::request", "Net"),
("sqlx::query", "Db"),
("sqlx::query_as", "Db"),
("sqlx::query_scalar", "Db"),
("sqlx::query_with", "Db"),
("sqlx::query_as_with", "Db"),
("diesel::sql_query", "Db"),
];
ENTRIES.iter().find(|(p, _)| *p == path).map(|(_, eff)| *eff)
}
#[derive(Clone, Default)]
pub(crate) struct DepFn {
pub(crate) effects: Vec<&'static str>,
pub(crate) hosts: Vec<String>,
pub(crate) cmds: Vec<String>,
pub(crate) paths: Vec<String>,
pub(crate) tables: Vec<String>,
pub(crate) invisible: Vec<String>,
pub(crate) incomplete: Vec<&'static str>,
}
#[derive(Default)]
pub(crate) struct DepIndex {
pub(crate) by_key: HashMap<String, DepFn>,
pub(crate) crates: std::collections::HashSet<String>,
}
pub(crate) fn load_dep_reports(spec: Option<&str>) -> DepIndex {
let mut idx = DepIndex::default();
let Some(spec) = spec else { return idx };
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut seen_files: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let mut push_file = |f: std::path::PathBuf, files: &mut Vec<std::path::PathBuf>| {
let canon = std::fs::canonicalize(&f).unwrap_or(f);
if seen_files.insert(canon.clone()) {
files.push(canon);
}
};
for tok in spec.split(':').filter(|t| !t.is_empty()) {
let p = Path::new(tok);
if p.is_dir() {
for e in walkdir::WalkDir::new(p).into_iter().filter_map(Result::ok) {
let f = e.path();
let name = f.file_name().and_then(|n| n.to_str()).unwrap_or("");
if f.is_file() && name.ends_with(".json") && !name.contains("callgraph") {
push_file(f.to_path_buf(), &mut files);
}
}
} else if p.is_file() {
push_file(p.to_path_buf(), &mut files);
} else {
eprintln!("candor-scan: CANDOR_DEPS entry not found, skipped: {tok}");
}
}
let my_version = format!("scan-{}", env!("CARGO_PKG_VERSION"));
let mut ambiguous: std::collections::HashSet<String> = std::collections::HashSet::new();
for f in &files {
let Ok(text) = std::fs::read_to_string(f) else {
eprintln!("candor-scan: CANDOR_DEPS report unreadable, skipped: {}", f.display());
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
eprintln!("candor-scan: CANDOR_DEPS report unparsable, skipped: {}", f.display());
continue;
};
let version = v.pointer("/candor/version").and_then(|x| x.as_str()).unwrap_or("");
let stale = version != my_version;
let Some(fns) = v.get("functions").and_then(|x| x.as_array()).or_else(|| v.as_array()) else { continue };
for pkg in v
.get("package")
.and_then(|x| x.as_str())
.into_iter()
.chain(v.get("packages").and_then(|x| x.as_array()).into_iter().flatten().filter_map(|x| x.as_str()))
{
idx.crates.insert(pkg.to_string());
idx.crates.insert(pkg.replace('-', "_"));
}
let file_crate = f
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_suffix(".scan.json"))
.and_then(|n| n.rsplit('.').next())
.map(str::to_string);
if let Some(c) = &file_crate {
idx.crates.insert(c.clone());
}
for e in fns {
let Some(qual) = e.get("fn").and_then(|x| x.as_str()) else { continue };
let krate = e
.get("hash")
.and_then(|x| x.as_str())
.and_then(|h| h.split_once('#'))
.map(|(c, _)| c.to_string())
.or_else(|| file_crate.clone());
let Some(krate) = krate else { continue };
idx.crates.insert(krate.clone());
let mut de = DepFn::default();
if stale {
de.effects.push("Unknown"); } else {
for s in e.get("inferred").and_then(|x| x.as_array()).into_iter().flatten() {
if let Some(s) = s.as_str() {
de.effects.push(candor_classify::cap_from_name(s).unwrap_or("Unknown"));
}
}
let strs = |k: &str| -> Vec<String> {
e.get(k)
.and_then(|x| x.as_array())
.into_iter()
.flatten()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
};
de.hosts = strs("hosts");
de.cmds = strs("cmds");
de.paths = strs("paths");
de.tables = strs("tables");
de.invisible = strs("invisible"); for s in e.get("incomplete").and_then(|x| x.as_array()).into_iter().flatten() {
if let Some(eff) = s.as_str().and_then(candor_classify::cap_from_name) {
de.incomplete.push(eff);
}
}
}
let mut keys = vec![format!("{krate}#{}", qual.rsplit("::").next().unwrap_or(qual))];
if let Some(t2) = tail2(qual) {
keys.push(format!("{krate}#{t2}"));
}
for k in keys {
if ambiguous.contains(&k) {
continue;
}
#[allow(clippy::map_entry)]
if idx.by_key.contains_key(&k) {
idx.by_key.remove(&k); ambiguous.insert(k);
} else {
idx.by_key.insert(k, de.clone());
}
}
}
}
idx
}
pub(crate) fn toml_section(line: &str) -> Option<&str> {
let l = line.trim();
Some(l.strip_prefix('[')?.strip_suffix(']')?.trim())
}
pub(crate) fn toml_scalar<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.trim().strip_prefix(key)?.trim_start().strip_prefix('=')?.trim();
Some(if let Some(q) = rest.strip_prefix('"') {
q.split('"').next().unwrap_or(q)
} else {
rest.split('#').next().unwrap_or(rest).trim()
})
}
pub(crate) fn cargo_deps(dir: &str) -> (std::collections::HashSet<String>, HashMap<String, String>) {
let mut out = std::collections::HashSet::new();
let mut renames = HashMap::new();
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 || !e.file_type().is_dir() {
return true;
}
let name = e.file_name().to_str().unwrap_or("");
if name == "target" || (name.starts_with('.') && name != "." && name != "..") {
return false;
}
!e.path().join("Cargo.toml").is_file()
})
.filter_map(Result::ok)
{
let p = entry.path();
if p.file_name().and_then(|n| n.to_str()) != Some("Cargo.toml") {
continue;
}
if let Ok(text) = std::fs::read_to_string(p) {
cargo_toml_deps(&text, &mut out, &mut renames);
}
}
(out, renames)
}
pub(crate) fn cargo_toml_deps(
text: &str,
out: &mut std::collections::HashSet<String>,
renames: &mut HashMap<String, String>,
) {
let pkg_re = |l: &str| -> Option<String> {
let bytes = l.as_bytes();
let mut search = 0;
while let Some(rel) = l[search..].find("package") {
let i = search + rel;
let boundary = i == 0 || matches!(bytes[i - 1], b'{' | b',' | b' ' | b'\t');
if boundary {
if let Some(rest) = l[i + "package".len()..].trim_start().strip_prefix('=') {
if let Some(rest) = rest.trim_start().strip_prefix('"') {
return rest.split('"').next().map(|s| s.replace('-', "_"));
}
}
}
search = i + "package".len();
}
None
};
let mut in_deps = false;
let mut header_key: Option<String> = None; for line in text.lines() {
let l = line.trim();
if let Some(inner) = toml_section(line) {
let harness = inner.contains("dev-dependencies") || inner.contains("build-dependencies");
in_deps = !harness && (inner == "dependencies" || inner.ends_with(".dependencies"));
header_key = None;
if !harness && !in_deps {
let name = inner
.rfind(".dependencies.")
.map(|i| &inner[i + ".dependencies.".len()..])
.or_else(|| inner.strip_prefix("dependencies."));
if let Some(name) = name {
if !name.is_empty() && !name.contains('.') {
let key = name.trim_matches('"').replace('-', "_");
out.insert(key.clone());
header_key = Some(key);
}
}
}
continue;
}
if l.is_empty() || l.starts_with('#') {
continue;
}
if let Some(key) = &header_key {
if l.starts_with("package") {
if let Some(real) = pkg_re(l) {
renames.insert(key.clone(), real);
}
}
continue;
}
if !in_deps {
continue;
}
if let Some(name) = l.split('=').next() {
let name = name.trim().trim_matches('"');
if !name.is_empty() {
let key = name.replace('-', "_");
if let Some(brace) = l.find('{') {
if let Some(real) = pkg_re(&l[brace..]) {
if real != key {
renames.insert(key.clone(), real);
}
}
}
out.insert(key);
}
}
}
}
pub(crate) fn dirs_cargo_registry_src() -> Vec<std::path::PathBuf> {
let home = std::env::var("CARGO_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| Path::new(&std::env::var("HOME").unwrap_or_default()).join(".cargo"));
std::fs::read_dir(home.join("registry").join("src"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
}
pub(crate) fn read_crate_name(root: &Path) -> Option<String> {
let txt = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
let mut in_package = false;
for line in txt.lines() {
if let Some(section) = toml_section(line) {
in_package = section == "package"; continue;
}
if in_package {
if let Some(v) = toml_scalar(line, "name") {
return Some(v.replace('-', "_"));
}
}
}
None
}
pub(crate) fn toml_string_array(txt: &str, table: &str, key: &str) -> Vec<String> {
let (mut in_table, mut collecting) = (false, false);
let mut out = Vec::new();
for line in txt.lines() {
let l = line.trim();
if !collecting {
if let Some(section) = toml_section(line) {
in_table = section == table;
continue;
}
}
if !in_table {
continue;
}
let rest = if let Some(r) = l.strip_prefix(key) {
let r = r.trim_start();
let Some(r) = r.strip_prefix('=') else { continue };
collecting = true;
r
} else if collecting {
l
} else {
continue;
};
let mut parts = rest.split('"');
parts.next();
while let Some(s) = parts.next() {
out.push(s.to_string());
if parts.next().is_none() {
break;
}
}
if rest.contains(']') {
collecting = false;
}
}
out
}
pub(crate) fn has_workspace_table(root: &Path) -> bool {
std::fs::read_to_string(root.join("Cargo.toml"))
.map(|t| t.lines().any(|l| l.trim() == "[workspace]"))
.unwrap_or(false)
}
pub(crate) fn workspace_members(root: &Path) -> Vec<String> {
let Ok(txt) = std::fs::read_to_string(root.join("Cargo.toml")) else { return Vec::new() };
let members = toml_string_array(&txt, "workspace", "members");
if members.is_empty() {
return Vec::new();
}
let exclude = toml_string_array(&txt, "workspace", "exclude");
let expand = |base: &str| -> Vec<String> {
let dir = if base.is_empty() { root.to_path_buf() } else { root.join(base) };
let mut found: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(Result::ok)
.filter(|e| e.path().join("Cargo.toml").is_file())
.map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
if base.is_empty() { n } else { format!("{base}/{n}") }
})
.collect();
found.sort();
found
};
let mut rels: Vec<String> = Vec::new();
for m in members {
if m == "*" {
rels.extend(expand(""));
} else if let Some(base) = m.strip_suffix("/*") {
rels.extend(expand(base));
} else if m.contains('*') {
eprintln!("candor-scan: workspace member glob `{m}` is not a trailing `*` — not expanded; \
scan its crates directly or list them explicitly");
} else if root.join(&m).join("Cargo.toml").is_file() {
rels.push(m);
}
}
rels.retain(|m| !exclude.iter().any(|e| m == e || m.starts_with(&format!("{e}/"))));
rels.sort();
rels.dedup();
rels.into_iter().map(|m| root.join(m).to_string_lossy().into_owned()).collect()
}